Skip to content

feat(dashboard,api-service): add self-hosted-auth #7755

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 14 commits into
base: next
Choose a base branch
from
Draft
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
13 changes: 9 additions & 4 deletions apps/api/src/app/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { MemberEntity, MemberRepository, UserRepository } from '@novu/dal';
import { MemberEntity, MemberRepository, OrganizationRepository, UserRepository } from '@novu/dal';
import { AuthGuard } from '@nestjs/passport';
import { PasswordResetFlowEnum, UserSessionData } from '@novu/shared';
import { ApiExcludeController, ApiTags } from '@nestjs/swagger';
Expand All @@ -38,11 +38,10 @@ import { UpdatePasswordBodyDto } from './dtos/update-password.dto';
import { UpdatePassword } from './usecases/update-password/update-password.usecase';
import { UpdatePasswordCommand } from './usecases/update-password/update-password.command';
import { UserAuthentication } from '../shared/framework/swagger/api.key.security';
import { SwitchEnvironmentCommand } from './usecases/switch-environment/switch-environment.command';
import { SwitchEnvironment } from './usecases/switch-environment/switch-environment.usecase';
import { SwitchOrganizationCommand } from './usecases/switch-organization/switch-organization.command';
import { SwitchOrganization } from './usecases/switch-organization/switch-organization.usecase';
import { AuthService } from './services/auth.service';
import { SystemOrganizationService } from './services/system-organization.service';

@ApiCommonResponses()
@Controller('/auth')
Expand All @@ -60,7 +59,8 @@ export class AuthController {
private passwordResetRequestUsecase: PasswordResetRequest,
private passwordResetUsecase: PasswordReset,
private updatePasswordUsecase: UpdatePassword,
private logger: PinoLogger
private logger: PinoLogger,
private systemOrganizationService: SystemOrganizationService
) {
this.logger.setContext(this.constructor.name);
}
Expand Down Expand Up @@ -190,4 +190,9 @@ export class AuthController {

return await this.authService.getSignedToken(user, organizationId, member as MemberEntity);
}

@Get('/self-hosted')
async logMeIn() {
return this.systemOrganizationService.getSystemOrganizationToken();
}
}
6 changes: 4 additions & 2 deletions apps/api/src/app/auth/community.auth.module.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AuthService } from './services/auth.service';
import { RolesGuard } from './framework/roles.guard';
import { CommunityAuthService } from './services/community.auth.service';
import { CommunityUserAuthGuard } from './framework/community.user.auth.guard';
import { SystemOrganizationService } from './services/system-organization.service';

const AUTH_STRATEGIES: Provider[] = [JwtStrategy, ApiKeyStrategy, JwtSubscriberStrategy];

Expand All @@ -39,7 +40,7 @@ export function getCommunityAuthModuleConfig(): ModuleMetadata {
}),
];

const baseProviders = [...AUTH_STRATEGIES, AuthService, RolesGuard, RootEnvironmentGuard];
const baseProviders = [...AUTH_STRATEGIES, AuthService, RolesGuard, RootEnvironmentGuard, SystemOrganizationService];

// Wherever is the string token used, override it with the provider
const injectableProviders = [
Expand Down Expand Up @@ -68,7 +69,7 @@ export function getCommunityAuthModuleConfig(): ModuleMetadata {
return {
imports: [...baseImports, EnvironmentsModuleV1, SharedModule, UserModule, OrganizationModule],
controllers: [AuthController],
providers: [...baseProviders, ...injectableProviders, ...USE_CASES],
providers: [...baseProviders, ...injectableProviders, ...USE_CASES, SystemOrganizationService],
exports: [
RolesGuard,
RootEnvironmentGuard,
Expand All @@ -78,6 +79,7 @@ export function getCommunityAuthModuleConfig(): ModuleMetadata {
'USER_REPOSITORY',
'MEMBER_REPOSITORY',
'ORGANIZATION_REPOSITORY',
SystemOrganizationService,
],
};
}
Expand Down
157 changes: 157 additions & 0 deletions apps/api/src/app/auth/services/system-organization.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { CommunityOrganizationRepository, MemberRepository, UserEntity, UserRepository } from '@novu/dal';
import { PinoLogger } from '@novu/application-generic';
import { ApiServiceLevelEnum } from '@novu/shared';
import { CreateOrganization } from '../../organization/usecases/create-organization/create-organization.usecase';
import { CreateOrganizationCommand } from '../../organization/usecases/create-organization/create-organization.command';
import { UserRegister } from '../usecases/register/user-register.usecase';
import { UserRegisterCommand } from '../usecases/register/user-register.command';
import { SwitchOrganization } from '../usecases/switch-organization/switch-organization.usecase';
import { SwitchOrganizationCommand } from '../usecases/switch-organization/switch-organization.command';

@Injectable()
export class SystemOrganizationService implements OnModuleInit {
private readonly E11000_DUPLICATE_KEY_ERROR_CODE = 'E11000';
private readonly SYSTEM_ORGANIZATION_NAME = 'System Organization';
private readonly SYSTEM_USER_EMAIL = '[email protected]';

constructor(
private memberRepository: MemberRepository,
@Inject('ORGANIZATION_REPOSITORY')
private organizationRepository: CommunityOrganizationRepository,
private createOrganizationUsecase: CreateOrganization,
private userRegisterUsecase: UserRegister,
private switchOrganizationUsecase: SwitchOrganization,
private userRepository: UserRepository,
private logger: PinoLogger
) {}

async onModuleInit() {
try {
await this.initializeSystemOrganization();
} catch (error) {
this.logger.error({ err: error }, 'Failed to initialize Self-Hosted System Setup during module init');
throw error;
}
}

private async initializeSystemOrganization(): Promise<void> {
await this.organizationRepository.withTransaction(async () => {
let systemOrg = await this.organizationRepository.findOne({ name: 'System Organization' });

if (systemOrg) {
this.logger.info(
`Self Hosted is already initialized, skipping System Organization creation. Organization already exists with ID: ${systemOrg._id}`
);

return;
}

this.logger.info('System Organization not found, creating it');

try {
let user = await this.userRepository.findByEmail(this.SYSTEM_USER_EMAIL);
if (!user) {
user = await this.createSystemUser();
}

this.logger.debug(`Retrieved System User with ID: ${user._id}`);

const organization = await this.createOrganizationUsecase.execute(
CreateOrganizationCommand.create({
userId: user._id,
name: this.SYSTEM_ORGANIZATION_NAME,
apiServiceLevel: ApiServiceLevelEnum.UNLIMITED,
})
);

this.logger.debug(`Retrieved System Organization with ID: ${organization?._id}`);
} catch (error) {
const isDuplicateKeyError =
error instanceof Error &&
error.message.includes(this.E11000_DUPLICATE_KEY_ERROR_CODE) &&
error.message.includes(this.SYSTEM_ORGANIZATION_NAME);

if (!isDuplicateKeyError) {
throw error;
}

this.logger.warn('Duplicate key error, another instance may have created the System Organization');
systemOrg = await this.organizationRepository.findOne({ name: 'System Organization' });
if (!systemOrg) {
this.logger.error('Failed to retrieve System Organization after duplicate key error');
throw error;
}

this.logger.info(`Retrieved System Organization created by another instance with ID: ${systemOrg._id}`);
}
});
}

private async createSystemUser(): Promise<UserEntity> {
try {
const { user } = await this.userRegisterUsecase.execute(
UserRegisterCommand.create({
email: this.SYSTEM_USER_EMAIL,
firstName: 'System',
lastName: 'User',
password: 'systemUser1q@W#',
})
);

if (!user?._id) {
throw new Error('Failed to create system user');
}

return user;
} catch (error) {
const isDuplicateKeyDatabaseError =
error instanceof Error &&
error.message.includes(this.E11000_DUPLICATE_KEY_ERROR_CODE) &&
error.message.includes(this.SYSTEM_USER_EMAIL);
const isUserAlreadyExistsUsecaseError = error.message.includes('User already exists');

if (!isDuplicateKeyDatabaseError && !isUserAlreadyExistsUsecaseError) {
throw error;
}

this.logger.warn('Duplicate key error, another instance may have created the System User');
const user = await this.userRepository.findByEmail(this.SYSTEM_USER_EMAIL);
if (!user) {
this.logger.error('Failed to retrieve System User after duplicate key error');
throw error;
}

this.logger.info(`Retrieved System User created by another instance with ID: ${user._id}`);

if (!user?._id) {
throw new Error('Failed to create system user');
}

return user;
}
}

async getSystemOrganizationToken() {
const systemOrg = await this.organizationRepository.findOne({ name: 'System Organization' });

if (!systemOrg) {
throw new Error('System Organization not found');
}

const users = await this.memberRepository.getOrganizationMembers(systemOrg._id);

if (!users || users.length === 0) {
throw new Error('No admin users found for System Organization');
}

const token = await this.switchOrganizationUsecase.execute(
SwitchOrganizationCommand.create({
newOrganizationId: systemOrg._id!,
userId: users[0]._userId,
})
);

return { token };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const enterpriseImports = (): Array<Type | DynamicModule | Promise<DynamicModule
}
} catch (e) {
logger.error(e, `Unexpected error while importing enterprise modules`);
throw e;
}

return modules;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator';

import { JobTitleEnum } from '@novu/shared';
import { ApiServiceLevelEnum, JobTitleEnum } from '@novu/shared';

import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command';

Expand All @@ -23,4 +23,8 @@ export class CreateOrganizationCommand extends AuthenticatedCommand {

@IsOptional()
language?: string[];

@IsOptional()
@IsEnum(ApiServiceLevelEnum)
apiServiceLevel?: ApiServiceLevelEnum;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Scope, BadRequestException } from '@nestjs/common';
import { Inject, BadRequestException, Injectable } from '@nestjs/common';
import { AnalyticsService } from '@novu/application-generic';
import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal';
import { ApiServiceLevelEnum, EnvironmentEnum, JobTitleEnum, MemberRoleEnum } from '@novu/shared';
Expand All @@ -11,9 +11,7 @@ import { AddMemberCommand } from '../membership/add-member/add-member.command';
import { AddMember } from '../membership/add-member/add-member.usecase';
import { CreateOrganizationCommand } from './create-organization.command';

@Injectable({
scope: Scope.REQUEST,
})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

is there any specific reason we're using request scope in the app? (there other providers in this PR that i update)
it’s currently limiting me from injecting certain providers into SystemOrganizationService, since it’s a singleton, and you can’t inject a request-scoped provider into a singleton.

cc @scopsy

@Injectable()
export class CreateOrganization {
constructor(
private readonly organizationRepository: OrganizationRepository,
Expand All @@ -31,7 +29,7 @@ export class CreateOrganization {
const createdOrganization = await this.organizationRepository.create({
logo: command.logo,
name: command.name,
apiServiceLevel: ApiServiceLevelEnum.FREE,
apiServiceLevel: command.apiServiceLevel || ApiServiceLevelEnum.FREE,
domain: command.domain,
language: command.language,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { Injectable, Scope } from '@nestjs/common';
import { OrganizationRepository } from '@novu/dal';
import { GetOrganizationCommand } from './get-organization.command';

@Injectable({
scope: Scope.REQUEST,
})
@Injectable()
export class GetOrganization {
constructor(private readonly organizationRepository: OrganizationRepository) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { MemberRepository } from '@novu/dal';
import { MemberStatusEnum } from '@novu/shared';
import { AddMemberCommand } from './add-member.command';

@Injectable({
scope: Scope.REQUEST,
})
@Injectable()
export class AddMember {
private organizationId: string;

Expand Down
7 changes: 5 additions & 2 deletions apps/dashboard/src/components/activity/activity-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-for
import { CHANNEL_OPTIONS } from './constants';
import { buildActivityDateFilters } from '@/utils/activityFilters';
import { useMemo } from 'react';
import { IS_SELF_HOSTED } from '../../config';
type Fields = 'dateRange' | 'workflows' | 'channels' | 'transactionId' | 'subscriberId';

export type ActivityFilters = {
Expand Down Expand Up @@ -58,13 +59,15 @@ export function ActivityFilters({
const { subscription } = useFetchSubscription();

const maxActivityFeedRetentionOptions = useMemo(() => {
if (!organization || !subscription) {
const missingSubscription = !subscription && !IS_SELF_HOSTED;

if (!organization || missingSubscription) {
return [];
}

return buildActivityDateFilters({
organization,
subscription,
apiServiceLevel: subscription?.apiServiceLevel,
}).map((option) => ({
...option,
icon: option.disabled ? UpgradeCtaIcon : undefined,
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/components/inbox-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export const InboxButton = () => {
* This displays a test inbox, where the user can see their test notifications appear
* in real-time.
*/
// todo change the self hosted appId to the environment identifier?
const appId = isTestPage ? currentEnvironment?.identifier : APP_ID;

const localizationTestSuffix = isTestPage ? ' (Test)' : '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ROUTES } from '@/utils/routes';
import { ApiServiceLevelEnum } from '@novu/shared';
import { Control } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { IS_SELF_HOSTED } from '../../../config';

type IntegrationFormData = {
name: string;
Expand Down Expand Up @@ -96,7 +97,7 @@ export function GeneralSettings({
</FormItem>
)}
/>
{isForInAppStep && (
{isForInAppStep && !IS_SELF_HOSTED && (
<FormField
control={control}
name="removeNovuBranding"
Expand Down
Loading
Loading