Browse Source

HBE-326 feature: server configuration through GraphQL API (#3591)

* feat: restart cmd added in aio service

* feat: nestjs config package added

* test: fix all broken test case

* feat: infra config module add with get-update-reset functionality

* test: fix test case failure

* feat: update infra configs mutation added

* feat: utilise ConfigService in util functions

* chore: remove saml stuff

* feat: removed saml stuffs

* fix: config service precedence

* fix: mailer module init with right env value

* feat: added mutations and query

* feat: add query infra-configs

* fix: mailer module init issue

* chore: smtp url validation added

* fix: all sso disabling is handled

* fix: pnpm i without db connection

* fix: allowedAuthProviders and enableAndDisableSSO

* fix: validateSMTPUrl check

* feat: get api added for fetch provider list

* feat: feedback resolve

* chore: update code comments

* fix: uppercase issue of VITE_ALLOWED_AUTH_PROVIDERS

* chore: update lockfile

* fix: add validation checks for MAILER_ADDRESS_FROM

* test: fix test case

* chore: feedback resolve

* chore: renamed an enum

* chore: app shutdown way changed

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Mir Arif Hasan 1 year ago
parent
commit
6abc0e6071

+ 1 - 0
docker-compose.yml

@@ -66,6 +66,7 @@ services:
   # The service that spins up all 3 services at once in one container
   hoppscotch-aio:
     container_name: hoppscotch-aio
+    restart: unless-stopped
     build:
       dockerfile: prod.Dockerfile
       context: .

+ 1 - 0
packages/hoppscotch-backend/package.json

@@ -28,6 +28,7 @@
     "@nestjs-modules/mailer": "^1.9.1",
     "@nestjs/apollo": "^12.0.9",
     "@nestjs/common": "^10.2.6",
+    "@nestjs/config": "^3.1.1",
     "@nestjs/core": "^10.2.6",
     "@nestjs/graphql": "^12.0.9",
     "@nestjs/jwt": "^10.1.1",

+ 14 - 0
packages/hoppscotch-backend/prisma/migrations/20231124104640_infra_config/migration.sql

@@ -0,0 +1,14 @@
+-- CreateTable
+CREATE TABLE "InfraConfig" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "value" TEXT,
+    "active" BOOLEAN NOT NULL DEFAULT true,
+    "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedOn" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "InfraConfig_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "InfraConfig_name_key" ON "InfraConfig"("name");

+ 9 - 0
packages/hoppscotch-backend/prisma/schema.prisma

@@ -209,3 +209,12 @@ enum TeamMemberRole {
   VIEWER
   EDITOR
 }
+
+model InfraConfig {
+  id        String   @id @default(cuid())
+  name      String   @unique
+  value     String?
+  active    Boolean  @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
+  createdOn DateTime @default(now()) @db.Timestamp(3)
+  updatedOn DateTime @updatedAt @db.Timestamp(3)
+}

+ 2 - 2
packages/hoppscotch-backend/src/admin/admin.module.ts

@@ -4,7 +4,6 @@ import { AdminService } from './admin.service';
 import { PrismaModule } from '../prisma/prisma.module';
 import { PubSubModule } from '../pubsub/pubsub.module';
 import { UserModule } from '../user/user.module';
-import { MailerModule } from '../mailer/mailer.module';
 import { TeamModule } from '../team/team.module';
 import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
 import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
@@ -12,19 +11,20 @@ import { TeamCollectionModule } from '../team-collection/team-collection.module'
 import { TeamRequestModule } from '../team-request/team-request.module';
 import { InfraResolver } from './infra.resolver';
 import { ShortcodeModule } from 'src/shortcode/shortcode.module';
+import { InfraConfigModule } from 'src/infra-config/infra-config.module';
 
 @Module({
   imports: [
     PrismaModule,
     PubSubModule,
     UserModule,
-    MailerModule,
     TeamModule,
     TeamInvitationModule,
     TeamEnvironmentsModule,
     TeamCollectionModule,
     TeamRequestModule,
     ShortcodeModule,
+    InfraConfigModule,
   ],
   providers: [InfraResolver, AdminResolver, AdminService],
   exports: [AdminService],

+ 3 - 0
packages/hoppscotch-backend/src/admin/admin.service.spec.ts

@@ -16,6 +16,7 @@ import {
   USER_ALREADY_INVITED,
 } from '../errors';
 import { ShortcodeService } from 'src/shortcode/shortcode.service';
+import { ConfigService } from '@nestjs/config';
 
 const mockPrisma = mockDeep<PrismaService>();
 const mockPubSub = mockDeep<PubSubService>();
@@ -27,6 +28,7 @@ const mockTeamInvitationService = mockDeep<TeamInvitationService>();
 const mockTeamCollectionService = mockDeep<TeamCollectionService>();
 const mockMailerService = mockDeep<MailerService>();
 const mockShortcodeService = mockDeep<ShortcodeService>();
+const mockConfigService = mockDeep<ConfigService>();
 
 const adminService = new AdminService(
   mockUserService,
@@ -39,6 +41,7 @@ const adminService = new AdminService(
   mockPrisma as any,
   mockMailerService,
   mockShortcodeService,
+  mockConfigService,
 );
 
 const invitedUsers: InvitedUsers[] = [

+ 3 - 1
packages/hoppscotch-backend/src/admin/admin.service.ts

@@ -25,6 +25,7 @@ import { TeamEnvironmentsService } from '../team-environments/team-environments.
 import { TeamInvitationService } from '../team-invitation/team-invitation.service';
 import { TeamMemberRole } from '../team/team.model';
 import { ShortcodeService } from 'src/shortcode/shortcode.service';
+import { ConfigService } from '@nestjs/config';
 
 @Injectable()
 export class AdminService {
@@ -39,6 +40,7 @@ export class AdminService {
     private readonly prisma: PrismaService,
     private readonly mailerService: MailerService,
     private readonly shortcodeService: ShortcodeService,
+    private readonly configService: ConfigService,
   ) {}
 
   /**
@@ -79,7 +81,7 @@ export class AdminService {
         template: 'user-invitation',
         variables: {
           inviteeEmail: inviteeEmail,
-          magicLink: `${process.env.VITE_BASE_URL}`,
+          magicLink: `${this.configService.get('VITE_BASE_URL')}`,
         },
       });
     } catch (e) {

+ 91 - 2
packages/hoppscotch-backend/src/admin/infra.resolver.ts

@@ -1,5 +1,12 @@
 import { UseGuards } from '@nestjs/common';
-import { Args, ID, Query, ResolveField, Resolver } from '@nestjs/graphql';
+import {
+  Args,
+  ID,
+  Mutation,
+  Query,
+  ResolveField,
+  Resolver,
+} from '@nestjs/graphql';
 import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
 import { Infra } from './infra.model';
 import { AdminService } from './admin.service';
@@ -16,11 +23,21 @@ import { Team } from 'src/team/team.model';
 import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
 import { GqlAdmin } from './decorators/gql-admin.decorator';
 import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model';
+import { InfraConfig } from 'src/infra-config/infra-config.model';
+import { InfraConfigService } from 'src/infra-config/infra-config.service';
+import {
+  EnableAndDisableSSOArgs,
+  InfraConfigArgs,
+} from 'src/infra-config/input-args';
+import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
 
 @UseGuards(GqlThrottlerGuard)
 @Resolver(() => Infra)
 export class InfraResolver {
-  constructor(private adminService: AdminService) {}
+  constructor(
+    private adminService: AdminService,
+    private infraConfigService: InfraConfigService,
+  ) {}
 
   @Query(() => Infra, {
     description: 'Fetch details of the Infrastructure',
@@ -222,4 +239,76 @@ export class InfraResolver {
       userEmail,
     );
   }
+
+  @Query(() => [InfraConfig], {
+    description: 'Retrieve configuration details for the instance',
+  })
+  @UseGuards(GqlAuthGuard, GqlAdminGuard)
+  async infraConfigs(
+    @Args({
+      name: 'configNames',
+      type: () => [InfraConfigEnumForClient],
+      description: 'Configs to fetch',
+    })
+    names: InfraConfigEnumForClient[],
+  ) {
+    const infraConfigs = await this.infraConfigService.getMany(names);
+    if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);
+    return infraConfigs.right;
+  }
+
+  @Query(() => [String], {
+    description: 'Allowed Auth Provider list',
+  })
+  @UseGuards(GqlAuthGuard, GqlAdminGuard)
+  allowedAuthProviders() {
+    return this.infraConfigService.getAllowedAuthProviders();
+  }
+
+  /* Mutations */
+
+  @Mutation(() => [InfraConfig], {
+    description: 'Update Infra Configs',
+  })
+  @UseGuards(GqlAuthGuard, GqlAdminGuard)
+  async updateInfraConfigs(
+    @Args({
+      name: 'infraConfigs',
+      type: () => [InfraConfigArgs],
+      description: 'InfraConfigs to update',
+    })
+    infraConfigs: InfraConfigArgs[],
+  ) {
+    const updatedRes = await this.infraConfigService.updateMany(infraConfigs);
+    if (E.isLeft(updatedRes)) throwErr(updatedRes.left);
+    return updatedRes.right;
+  }
+
+  @Mutation(() => Boolean, {
+    description: 'Reset Infra Configs with default values (.env)',
+  })
+  @UseGuards(GqlAuthGuard, GqlAdminGuard)
+  async resetInfraConfigs() {
+    const resetRes = await this.infraConfigService.reset();
+    if (E.isLeft(resetRes)) throwErr(resetRes.left);
+    return true;
+  }
+
+  @Mutation(() => Boolean, {
+    description: 'Enable or Disable SSO for login/signup',
+  })
+  @UseGuards(GqlAuthGuard, GqlAdminGuard)
+  async enableAndDisableSSO(
+    @Args({
+      name: 'providerInfo',
+      type: () => [EnableAndDisableSSOArgs],
+      description: 'SSO provider and status',
+    })
+    providerInfo: EnableAndDisableSSOArgs[],
+  ) {
+    const isUpdated = await this.infraConfigService.enableAndDisableSSO(providerInfo);
+    if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
+
+    return true;
+  }
 }

+ 57 - 38
packages/hoppscotch-backend/src/app.module.ts

@@ -20,51 +20,69 @@ import { ShortcodeModule } from './shortcode/shortcode.module';
 import { COOKIES_NOT_FOUND } from './errors';
 import { ThrottlerModule } from '@nestjs/throttler';
 import { AppController } from './app.controller';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import { InfraConfigModule } from './infra-config/infra-config.module';
+import { loadInfraConfiguration } from './infra-config/helper';
+import { MailerModule } from './mailer/mailer.module';
 
 @Module({
   imports: [
-    GraphQLModule.forRoot<ApolloDriverConfig>({
-      buildSchemaOptions: {
-        numberScalarMode: 'integer',
-      },
-      playground: process.env.PRODUCTION !== 'true',
-      autoSchemaFile: true,
-      installSubscriptionHandlers: true,
-      subscriptions: {
-        'subscriptions-transport-ws': {
-          path: '/graphql',
-          onConnect: (_, websocket) => {
-            try {
-              const cookies = subscriptionContextCookieParser(
-                websocket.upgradeReq.headers.cookie,
-              );
-
-              return {
-                headers: { ...websocket?.upgradeReq?.headers, cookies },
-              };
-            } catch (error) {
-              throw new HttpException(COOKIES_NOT_FOUND, 400, {
-                cause: new Error(COOKIES_NOT_FOUND),
-              });
-            }
+    ConfigModule.forRoot({
+      isGlobal: true,
+      load: [async () => loadInfraConfiguration()],
+    }),
+    GraphQLModule.forRootAsync<ApolloDriverConfig>({
+      driver: ApolloDriver,
+      imports: [ConfigModule],
+      inject: [ConfigService],
+      useFactory: async (configService: ConfigService) => {
+        return {
+          buildSchemaOptions: {
+            numberScalarMode: 'integer',
           },
-        },
+          playground: configService.get('PRODUCTION') !== 'true',
+          autoSchemaFile: true,
+          installSubscriptionHandlers: true,
+          subscriptions: {
+            'subscriptions-transport-ws': {
+              path: '/graphql',
+              onConnect: (_, websocket) => {
+                try {
+                  const cookies = subscriptionContextCookieParser(
+                    websocket.upgradeReq.headers.cookie,
+                  );
+                  return {
+                    headers: { ...websocket?.upgradeReq?.headers, cookies },
+                  };
+                } catch (error) {
+                  throw new HttpException(COOKIES_NOT_FOUND, 400, {
+                    cause: new Error(COOKIES_NOT_FOUND),
+                  });
+                }
+              },
+            },
+          },
+          context: ({ req, res, connection }) => ({
+            req,
+            res,
+            connection,
+          }),
+        };
       },
-      context: ({ req, res, connection }) => ({
-        req,
-        res,
-        connection,
-      }),
-      driver: ApolloDriver,
     }),
-    ThrottlerModule.forRoot([
-      {
-        ttl: +process.env.RATE_LIMIT_TTL,
-        limit: +process.env.RATE_LIMIT_MAX,
-      },
-    ]),
+    ThrottlerModule.forRootAsync({
+      imports: [ConfigModule],
+      inject: [ConfigService],
+      useFactory: async (configService: ConfigService) => [
+        {
+          ttl: +configService.get('RATE_LIMIT_TTL'),
+          limit: +configService.get('RATE_LIMIT_MAX'),
+        },
+      ],
+    }),
+    MailerModule.register(),
     UserModule,
-    AuthModule,
+    AuthModule.register(),
     AdminModule,
     UserSettingsModule,
     UserEnvironmentsModule,
@@ -77,6 +95,7 @@ import { AppController } from './app.controller';
     TeamInvitationModule,
     UserCollectionModule,
     ShortcodeModule,
+    InfraConfigModule,
   ],
   providers: [GQLComplexityPlugin],
   controllers: [AppController],

+ 18 - 3
packages/hoppscotch-backend/src/auth/auth.controller.ts

@@ -2,7 +2,6 @@ import {
   Body,
   Controller,
   Get,
-  InternalServerErrorException,
   Post,
   Query,
   Request,
@@ -31,11 +30,21 @@ import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
 import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
 import { SkipThrottle } from '@nestjs/throttler';
 import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
+import { ConfigService } from '@nestjs/config';
 
 @UseGuards(ThrottlerBehindProxyGuard)
 @Controller({ path: 'auth', version: '1' })
 export class AuthController {
-  constructor(private authService: AuthService) {}
+  constructor(
+    private authService: AuthService,
+    private configService: ConfigService,
+  ) {}
+
+  @Get('providers')
+  async getAuthProviders() {
+    const providers = await this.authService.getAuthProviders();
+    return { providers };
+  }
 
   /**
    ** Route to initiate magic-link auth for a users email
@@ -45,8 +54,14 @@ export class AuthController {
     @Body() authData: SignInMagicDto,
     @Query('origin') origin: string,
   ) {
-    if (!authProviderCheck(AuthProvider.EMAIL))
+    if (
+      !authProviderCheck(
+        AuthProvider.EMAIL,
+        this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
+      )
+    ) {
       throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
+    }
 
     const deviceIdToken = await this.authService.signInMagicLink(
       authData.email,

Some files were not shown because too many files changed in this diff