From 8fbad7d3babd0006cc5359c1289890d942191611 Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 12 Oct 2023 18:07:44 +0200 Subject: [PATCH] 1043 timebox prepare zapier integration (#1967) * Add create api-key route * Import module * Remove required mutation parameter * Fix Authentication * Generate random key * Update Read ApiKeyAbility handler * Add findMany apiKey route * Remove useless attribute * Use signed token for apiKeys * Authenticate with api keys * Fix typo * Add a test for apiKey module * Revoke token when api key does not exist * Handler expiresAt parameter * Fix user passport * Code review returns: Add API_TOKEN_SECRET * Code review returns: Rename variable * Code review returns: Update code style * Update apiKey schema * Update create token route * Update delete token route * Filter revoked api keys from listApiKeys * Rename endpoint * Set default expiry to 2 years * Code review returns: Update comment * Generate token after create apiKey * Code review returns: Update env variable * Code review returns: Move method to proper service --------- Co-authored-by: martmull --- render.yaml | 2 + server/.env.example | 4 +- server/.env.test | 7 +- server/src/ability/ability.factory.ts | 58 +++++++++---- server/src/ability/ability.module.ts | 16 ++++ .../handlers/api-key.ability-handler.ts | 85 +++++++++++++++++++ server/src/app.module.ts | 6 +- server/src/core/api-key/api-key.module.ts | 12 +++ .../src/core/api-key/api-key.resolver.spec.ts | 28 ++++++ server/src/core/api-key/api-key.resolver.ts | 82 ++++++++++++++++++ server/src/core/api-key/api-key.service.ts | 63 ++++++++++++++ .../core/auth/strategies/jwt.auth.strategy.ts | 38 +++++---- server/src/core/core.module.ts | 3 + .../migration.sql | 22 +++++ .../migration.sql | 12 +++ server/src/database/schema.prisma | 21 +++++ server/src/guards/ability.guard.ts | 2 +- .../environment/environment.service.ts | 8 ++ .../environment/environment.validation.ts | 2 + .../utils/prisma-select/model-select-map.ts | 1 + 20 files changed, 430 insertions(+), 42 deletions(-) create mode 100644 server/src/ability/handlers/api-key.ability-handler.ts create mode 100644 server/src/core/api-key/api-key.module.ts create mode 100644 server/src/core/api-key/api-key.resolver.spec.ts create mode 100644 server/src/core/api-key/api-key.resolver.ts create mode 100644 server/src/core/api-key/api-key.service.ts create mode 100644 server/src/database/migrations/20231010133527_complete_comment_thread_migration/migration.sql create mode 100644 server/src/database/migrations/20231012121531_complete_comment_thread_migration/migration.sql diff --git a/render.yaml b/render.yaml index d6961e5db..c195b8405 100644 --- a/render.yaml +++ b/render.yaml @@ -21,6 +21,8 @@ services: generateValue: true - key: LOGIN_TOKEN_SECRET generateValue: true + - key: API_TOKEN_SECRET + generateValue: true - key: REFRESH_TOKEN_SECRET generateValue: true - key: PG_DATABASE_URL diff --git a/server/.env.example b/server/.env.example index db468d7ef..c580e25fd 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,6 +6,7 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default?connection_limit FRONT_BASE_URL=http://localhost:3001 ACCESS_TOKEN_SECRET=replace_me_with_a_random_string LOGIN_TOKEN_SECRET=replace_me_with_a_random_string +API_TOKEN_SECRET=replace_me_with_a_random_string REFRESH_TOKEN_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true @@ -14,6 +15,7 @@ SIGN_IN_PREFILLED=true # DEBUG_MODE=true # ACCESS_TOKEN_EXPIRES_IN=30m # LOGIN_TOKEN_EXPIRES_IN=15m +# API_TOKEN_EXPIRES_IN=2y # REFRESH_TOKEN_EXPIRES_IN=90d # FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify # AUTH_GOOGLE_ENABLED=false @@ -25,4 +27,4 @@ SIGN_IN_PREFILLED=true # LOGGER_DRIVER=console # SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx # LOG_LEVEL=error,warn -# FLEXIBLE_BACKEND_ENABLED=false \ No newline at end of file +# FLEXIBLE_BACKEND_ENABLED=false diff --git a/server/.env.test b/server/.env.test index fdcbbc9dd..0ba1ed8b9 100644 --- a/server/.env.test +++ b/server/.env.test @@ -6,9 +6,10 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test?connection_limit=1 # the URL of the front-end app FRONT_BASE_URL=http://localhost:3001 # random keys used to generate JWT tokens -ACCESS_TOKEN_SECRET=secret_jwt +ACCESS_TOKEN_SECRET=secret_jwt LOGIN_TOKEN_SECRET=secret_login_tokens -REFRESH_TOKEN_SECRET=secret_refresh_token +API_TOKEN_SECRET=secret_api_tokens +REFRESH_TOKEN_SECRET=secret_refresh_token # ———————— Optional ———————— @@ -20,4 +21,4 @@ REFRESH_TOKEN_SECRET=secret_refresh_token # FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify # AUTH_GOOGLE_ENABLED=false # STORAGE_TYPE=local -# STORAGE_LOCAL_PATH=.local-storage \ No newline at end of file +# STORAGE_LOCAL_PATH=.local-storage diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts index 5fca3d3a5..a956c7cf1 100644 --- a/server/src/ability/ability.factory.ts +++ b/server/src/ability/ability.factory.ts @@ -6,6 +6,7 @@ import { Activity, ActivityTarget, Attachment, + ApiKey, Comment, Company, Favorite, @@ -30,6 +31,7 @@ type SubjectsAbility = Subjects<{ Activity: Activity; ActivityTarget: ActivityTarget; Attachment: Attachment; + ApiKey: ApiKey; Comment: Comment; Company: Company; Favorite: Favorite; @@ -55,7 +57,7 @@ export type AppAbility = PureAbility< @Injectable() export class AbilityFactory { - defineAbility(user: User, workspace: Workspace) { + defineAbility(workspace: Workspace, user?: User) { const { can, cannot, build } = new AbilityBuilder( createPrismaAbility, ); @@ -66,8 +68,18 @@ export class AbilityFactory { workspaceId: workspace.id, }, }); - can(AbilityAction.Update, 'User', { id: user.id }); - can(AbilityAction.Delete, 'User', { id: user.id }); + if (user) { + can(AbilityAction.Update, 'User', { id: user.id }); + can(AbilityAction.Delete, 'User', { id: user.id }); + } else { + cannot(AbilityAction.Update, 'User'); + cannot(AbilityAction.Delete, 'User'); + } + + // ApiKey + can(AbilityAction.Read, 'ApiKey', { workspaceId: workspace.id }); + can(AbilityAction.Create, 'ApiKey'); + can(AbilityAction.Update, 'ApiKey', { workspaceId: workspace.id }); // Workspace can(AbilityAction.Read, 'Workspace'); @@ -76,12 +88,19 @@ export class AbilityFactory { // Workspace Member can(AbilityAction.Read, 'WorkspaceMember', { workspaceId: workspace.id }); - can(AbilityAction.Delete, 'WorkspaceMember', { workspaceId: workspace.id }); - cannot(AbilityAction.Delete, 'WorkspaceMember', { userId: user.id }); - can(AbilityAction.Update, 'WorkspaceMember', { - userId: user.id, - workspaceId: workspace.id, - }); + if (user) { + can(AbilityAction.Delete, 'WorkspaceMember', { + workspaceId: workspace.id, + }); + cannot(AbilityAction.Delete, 'WorkspaceMember', { userId: user.id }); + can(AbilityAction.Update, 'WorkspaceMember', { + userId: user.id, + workspaceId: workspace.id, + }); + } else { + cannot(AbilityAction.Delete, 'WorkspaceMember'); + cannot(AbilityAction.Update, 'WorkspaceMember'); + } // Company can(AbilityAction.Read, 'Company', { workspaceId: workspace.id }); @@ -107,14 +126,19 @@ export class AbilityFactory { // Comment can(AbilityAction.Read, 'Comment', { workspaceId: workspace.id }); can(AbilityAction.Create, 'Comment'); - can(AbilityAction.Update, 'Comment', { - workspaceId: workspace.id, - authorId: user.id, - }); - can(AbilityAction.Delete, 'Comment', { - workspaceId: workspace.id, - authorId: user.id, - }); + if (user) { + can(AbilityAction.Update, 'Comment', { + workspaceId: workspace.id, + authorId: user.id, + }); + can(AbilityAction.Delete, 'Comment', { + workspaceId: workspace.id, + authorId: user.id, + }); + } else { + cannot(AbilityAction.Update, 'Comment'); + cannot(AbilityAction.Delete, 'Comment'); + } // ActivityTarget can(AbilityAction.Read, 'ActivityTarget'); diff --git a/server/src/ability/ability.module.ts b/server/src/ability/ability.module.ts index e90d977c5..f1aaa1d6b 100644 --- a/server/src/ability/ability.module.ts +++ b/server/src/ability/ability.module.ts @@ -122,6 +122,12 @@ import { ReadViewFilterAbilityHandler, UpdateViewFilterAbilityHandler, } from './handlers/view-filter.ability-handler'; +import { + CreateApiKeyAbilityHandler, + UpdateApiKeyAbilityHandler, + ManageApiKeyAbilityHandler, + ReadApiKeyAbilityHandler, +} from './handlers/api-key.ability-handler'; @Global() @Module({ @@ -229,6 +235,11 @@ import { CreateViewSortAbilityHandler, UpdateViewSortAbilityHandler, DeleteViewSortAbilityHandler, + // ApiKey + ReadApiKeyAbilityHandler, + ManageApiKeyAbilityHandler, + CreateApiKeyAbilityHandler, + UpdateApiKeyAbilityHandler, ], exports: [ AbilityFactory, @@ -333,6 +344,11 @@ import { CreateViewSortAbilityHandler, UpdateViewSortAbilityHandler, DeleteViewSortAbilityHandler, + // ApiKey + ReadApiKeyAbilityHandler, + ManageApiKeyAbilityHandler, + CreateApiKeyAbilityHandler, + UpdateApiKeyAbilityHandler, ], }) export class AbilityModule {} diff --git a/server/src/ability/handlers/api-key.ability-handler.ts b/server/src/ability/handlers/api-key.ability-handler.ts new file mode 100644 index 000000000..a72a6f4c6 --- /dev/null +++ b/server/src/ability/handlers/api-key.ability-handler.ts @@ -0,0 +1,85 @@ +import { + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +import { subject } from '@casl/ability'; + +import { IAbilityHandler } from 'src/ability/interfaces/ability-handler.interface'; + +import { AppAbility } from 'src/ability/ability.factory'; +import { AbilityAction } from 'src/ability/ability.action'; +import { PrismaService } from 'src/database/prisma.service'; +import { ApiKeyWhereUniqueInput } from 'src/core/@generated/api-key/api-key-where-unique.input'; +import { ApiKeyWhereInput } from 'src/core/@generated/api-key/api-key-where.input'; +import { assert } from 'src/utils/assert'; +import { + convertToWhereInput, + relationAbilityChecker, +} from 'src/ability/ability.util'; + +class ApiKeyArgs { + where?: ApiKeyWhereUniqueInput | ApiKeyWhereInput; + [key: string]: any; +} + +@Injectable() +export class ManageApiKeyAbilityHandler implements IAbilityHandler { + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Manage, 'ApiKey'); + } +} + +@Injectable() +export class ReadApiKeyAbilityHandler implements IAbilityHandler { + async handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'ApiKey'); + } +} + +@Injectable() +export class CreateApiKeyAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const allowed = await relationAbilityChecker( + 'ApiKey', + ability, + this.prismaService.client, + args, + ); + if (!allowed) { + return false; + } + return ability.can(AbilityAction.Create, 'ApiKey'); + } +} + +@Injectable() +export class UpdateApiKeyAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const where = convertToWhereInput(args.where); + const apiKey = await this.prismaService.client.apiKey.findFirst({ + where, + }); + assert(apiKey, '', NotFoundException); + const allowed = await relationAbilityChecker( + 'ApiKey', + ability, + this.prismaService.client, + args, + ); + if (!allowed) { + return false; + } + return ability.can(AbilityAction.Update, subject('ApiKey', apiKey)); + } +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index d25289c70..c47fe7605 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -74,11 +74,7 @@ import { ExceptionFilter } from './filters/exception.filter'; decoded as JwtPayload, ); - const conditionalSchema = await tenantService.createTenantSchema( - workspace.id, - ); - - return conditionalSchema; + return await tenantService.createTenantSchema(workspace.id); } catch (error) { if (error instanceof JsonWebTokenError) { //mockedUserJWT diff --git a/server/src/core/api-key/api-key.module.ts b/server/src/core/api-key/api-key.module.ts new file mode 100644 index 000000000..c716a412b --- /dev/null +++ b/server/src/core/api-key/api-key.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +import { TokenService } from 'src/core/auth/services/token.service'; + +import { ApiKeyResolver } from './api-key.resolver'; +import { ApiKeyService } from './api-key.service'; + +@Module({ + providers: [ApiKeyResolver, ApiKeyService, TokenService, JwtService], +}) +export class ApiKeyModule {} diff --git a/server/src/core/api-key/api-key.resolver.spec.ts b/server/src/core/api-key/api-key.resolver.spec.ts new file mode 100644 index 000000000..32f24e23c --- /dev/null +++ b/server/src/core/api-key/api-key.resolver.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; + +import { AbilityFactory } from 'src/ability/ability.factory'; +import { TokenService } from 'src/core/auth/services/token.service'; + +import { ApiKeyResolver } from './api-key.resolver'; +import { ApiKeyService } from './api-key.service'; + +describe('ApiKeyResolver', () => { + let resolver: ApiKeyResolver; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyResolver, + { provide: ApiKeyService, useValue: {} }, + { provide: TokenService, useValue: {} }, + { provide: JwtService, useValue: {} }, + { provide: AbilityFactory, useValue: {} }, + ], + }).compile(); + resolver = module.get(ApiKeyResolver); + }); + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); +}); diff --git a/server/src/core/api-key/api-key.resolver.ts b/server/src/core/api-key/api-key.resolver.ts new file mode 100644 index 000000000..96e7ec259 --- /dev/null +++ b/server/src/core/api-key/api-key.resolver.ts @@ -0,0 +1,82 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { NotFoundException, UseGuards } from '@nestjs/common'; + +import { accessibleBy } from '@casl/prisma'; + +import { AbilityGuard } from 'src/guards/ability.guard'; +import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { Workspace } from 'src/core/@generated/workspace/workspace.model'; +import { CreateOneApiKeyArgs } from 'src/core/@generated/api-key/create-one-api-key.args'; +import { ApiKey } from 'src/core/@generated/api-key/api-key.model'; +import { FindManyApiKeyArgs } from 'src/core/@generated/api-key/find-many-api-key.args'; +import { DeleteOneApiKeyArgs } from 'src/core/@generated/api-key/delete-one-api-key.args'; +import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; +import { + CreateApiKeyAbilityHandler, + UpdateApiKeyAbilityHandler, + ReadApiKeyAbilityHandler, +} from 'src/ability/handlers/api-key.ability-handler'; +import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; +import { UserAbility } from 'src/decorators/user-ability.decorator'; +import { AppAbility } from 'src/ability/ability.factory'; +import { AuthToken } from 'src/core/auth/dto/token.entity'; + +import { ApiKeyService } from './api-key.service'; + +@UseGuards(JwtAuthGuard) +@Resolver(() => ApiKey) +export class ApiKeyResolver { + constructor(private readonly apiKeyService: ApiKeyService) {} + + @Mutation(() => AuthToken) + @UseGuards(AbilityGuard) + @CheckAbilities(CreateApiKeyAbilityHandler) + async createOneApiKey( + @Args() args: CreateOneApiKeyArgs, + @AuthWorkspace() { id: workspaceId }: Workspace, + ): Promise { + return await this.apiKeyService.generateApiKeyToken( + workspaceId, + args.data.name, + args.data.expiresAt, + ); + } + + @Mutation(() => ApiKey) + @UseGuards(AbilityGuard) + @CheckAbilities(UpdateApiKeyAbilityHandler) + async revokeOneApiKey( + @Args() args: DeleteOneApiKeyArgs, + ): Promise> { + const apiKeyToDelete = await this.apiKeyService.findFirst({ + where: { ...args.where }, + }); + if (!apiKeyToDelete) { + throw new NotFoundException(); + } + return this.apiKeyService.update({ + where: args.where, + data: { + revokedAt: new Date(), + }, + }); + } + + @Query(() => [ApiKey]) + @UseGuards(AbilityGuard) + @CheckAbilities(ReadApiKeyAbilityHandler) + async findManyApiKey( + @Args() args: FindManyApiKeyArgs, + @UserAbility() ability: AppAbility, + ) { + const filterOptions = [ + accessibleBy(ability).WorkspaceMember, + { revokedAt: null }, + ]; + if (args.where) filterOptions.push(args.where); + return this.apiKeyService.findMany({ + ...args, + where: { AND: filterOptions }, + }); + } +} diff --git a/server/src/core/api-key/api-key.service.ts b/server/src/core/api-key/api-key.service.ts new file mode 100644 index 000000000..208d9b9cb --- /dev/null +++ b/server/src/core/api-key/api-key.service.ts @@ -0,0 +1,63 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +import { addMilliseconds, addSeconds } from 'date-fns'; +import ms from 'ms'; + +import { PrismaService } from 'src/database/prisma.service'; +import { AuthToken } from 'src/core/auth/dto/token.entity'; +import { assert } from 'src/utils/assert'; +import { EnvironmentService } from 'src/integrations/environment/environment.service'; + +@Injectable() +export class ApiKeyService { + constructor( + private readonly prismaService: PrismaService, + private readonly environmentService: EnvironmentService, + private readonly jwtService: JwtService, + ) {} + + findFirst = this.prismaService.client.apiKey.findFirst; + findUniqueOrThrow = this.prismaService.client.apiKey.findUniqueOrThrow; + findMany = this.prismaService.client.apiKey.findMany; + create = this.prismaService.client.apiKey.create; + update = this.prismaService.client.apiKey.update; + delete = this.prismaService.client.apiKey.delete; + + async generateApiKeyToken( + workspaceId: string, + name: string, + expiresAt?: Date | string, + ): Promise { + const secret = this.environmentService.getApiTokenSecret(); + let expiresIn: string | number; + let expirationDate: Date; + const now = new Date().getTime(); + if (expiresAt) { + expiresIn = Math.floor((new Date(expiresAt).getTime() - now) / 1000); + expirationDate = addSeconds(now, expiresIn); + } else { + expiresIn = this.environmentService.getApiTokenExpiresIn(); + expirationDate = addMilliseconds(now, ms(expiresIn)); + } + assert(expiresIn, '', InternalServerErrorException); + const jwtPayload = { + sub: workspaceId, + }; + const { id } = await this.prismaService.client.apiKey.create({ + data: { + expiresAt: expiresAt, + name: name, + workspaceId: workspaceId, + }, + }); + return { + token: this.jwtService.sign(jwtPayload, { + secret, + expiresIn, + jwtid: id, + }), + expiresAt: expirationDate, + }; + } +} diff --git a/server/src/core/auth/strategies/jwt.auth.strategy.ts b/server/src/core/auth/strategies/jwt.auth.strategy.ts index 8e64cff5c..e12003fcd 100644 --- a/server/src/core/auth/strategies/jwt.auth.strategy.ts +++ b/server/src/core/auth/strategies/jwt.auth.strategy.ts @@ -1,14 +1,19 @@ import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import { Strategy, ExtractJwt } from 'passport-jwt'; import { User, Workspace } from '@prisma/client'; import { PrismaService } from 'src/database/prisma.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { assert } from 'src/utils/assert'; -export type JwtPayload = { sub: string; workspaceId: string }; -export type PassportUser = { user: User; workspace: Workspace }; +export type JwtPayload = { sub: string; workspaceId: string; jti?: string }; +export type PassportUser = { user?: User; workspace: Workspace }; @Injectable() export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -24,22 +29,25 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { } async validate(payload: JwtPayload): Promise { - const user = await this.prismaService.client.user.findUniqueOrThrow({ - where: { id: payload.sub }, + const workspace = await this.prismaService.client.workspace.findUnique({ + where: { id: payload.workspaceId ?? payload.sub }, }); - - if (!user) { - throw new UnauthorizedException(); - } - - const workspace = - await this.prismaService.client.workspace.findUniqueOrThrow({ - where: { id: payload.workspaceId }, - }); - if (!workspace) { throw new UnauthorizedException(); } + if (payload.jti) { + // If apiKey has been deleted or revoked, we throw an error + const apiKey = await this.prismaService.client.apiKey.findUniqueOrThrow({ + where: { id: payload.jti }, + }); + assert(!apiKey.revokedAt, 'This API Key is revoked', ForbiddenException); + } + + const user = payload.workspaceId + ? await this.prismaService.client.user.findUniqueOrThrow({ + where: { id: payload.sub }, + }) + : undefined; return { user, workspace }; } diff --git a/server/src/core/core.module.ts b/server/src/core/core.module.ts index c250f24de..19044ad64 100644 --- a/server/src/core/core.module.ts +++ b/server/src/core/core.module.ts @@ -14,6 +14,7 @@ import { AttachmentModule } from './attachment/attachment.module'; import { ActivityModule } from './activity/activity.module'; import { ViewModule } from './view/view.module'; import { FavoriteModule } from './favorite/favorite.module'; +import { ApiKeyModule } from './api-key/api-key.module'; @Module({ imports: [ @@ -31,6 +32,7 @@ import { FavoriteModule } from './favorite/favorite.module'; ActivityModule, ViewModule, FavoriteModule, + ApiKeyModule, ], exports: [ AuthModule, @@ -43,6 +45,7 @@ import { FavoriteModule } from './favorite/favorite.module'; AnalyticsModule, AttachmentModule, FavoriteModule, + ApiKeyModule, ], }) export class CoreModule {} diff --git a/server/src/database/migrations/20231010133527_complete_comment_thread_migration/migration.sql b/server/src/database/migrations/20231010133527_complete_comment_thread_migration/migration.sql new file mode 100644 index 000000000..f30c55293 --- /dev/null +++ b/server/src/database/migrations/20231010133527_complete_comment_thread_migration/migration.sql @@ -0,0 +1,22 @@ +-- AlterEnum +ALTER TYPE "ViewFilterOperand" ADD VALUE 'IsNotNull'; + +-- CreateTable +CREATE TABLE "api_keys" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_keys_key_key" ON "api_keys"("key"); + +-- AddForeignKey +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/src/database/migrations/20231012121531_complete_comment_thread_migration/migration.sql b/server/src/database/migrations/20231012121531_complete_comment_thread_migration/migration.sql new file mode 100644 index 000000000..eda2606fa --- /dev/null +++ b/server/src/database/migrations/20231012121531_complete_comment_thread_migration/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `key` on the `api_keys` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "api_keys_key_key"; + +-- AlterTable +ALTER TABLE "api_keys" DROP COLUMN "key", +ADD COLUMN "revokedAt" TIMESTAMP(3); diff --git a/server/src/database/schema.prisma b/server/src/database/schema.prisma index 73d3281c3..3d6a60362 100644 --- a/server/src/database/schema.prisma +++ b/server/src/database/schema.prisma @@ -178,6 +178,7 @@ model Workspace { viewFilters ViewFilter[] views View[] viewSorts ViewSort[] + apiKeys ApiKey[] /// @TypeGraphQL.omit(input: true, output: true) deletedAt DateTime? @@ -886,3 +887,23 @@ model ViewField { @@id([viewId, key]) @@map("viewFields") } + +model ApiKey { + /// @Validator.IsString() + /// @Validator.IsOptional() + id String @id @default(uuid()) + name String + /// @TypeGraphQL.omit(input: true, output: true) + workspace Workspace @relation(fields: [workspaceId], references: [id]) + /// @TypeGraphQL.omit(input: true, output: true) + workspaceId String + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + /// @TypeGraphQL.omit(input: true, output: true) + deletedAt DateTime? + /// @TypeGraphQL.omit(input: true, output: true) + revokedAt DateTime? + + @@map("api_keys") +} diff --git a/server/src/guards/ability.guard.ts b/server/src/guards/ability.guard.ts index 44b0e26e8..4b7a626e7 100644 --- a/server/src/guards/ability.guard.ts +++ b/server/src/guards/ability.guard.ts @@ -35,8 +35,8 @@ export class AbilityGuard implements CanActivate { assert(passportUser, '', UnauthorizedException); const ability = this.abilityFactory.defineAbility( - passportUser.user, passportUser.workspace, + passportUser.user, ); request.ability = ability; diff --git a/server/src/integrations/environment/environment.service.ts b/server/src/integrations/environment/environment.service.ts index ed19468dd..1c7acea16 100644 --- a/server/src/integrations/environment/environment.service.ts +++ b/server/src/integrations/environment/environment.service.ts @@ -69,10 +69,18 @@ export class EnvironmentService { return this.configService.get('LOGIN_TOKEN_SECRET')!; } + getApiTokenSecret(): string { + return this.configService.get('API_TOKEN_SECRET')!; + } + getLoginTokenExpiresIn(): string { return this.configService.get('LOGIN_TOKEN_EXPIRES_IN') ?? '15m'; } + getApiTokenExpiresIn(): string { + return this.configService.get('API_TOKEN_EXPIRES_IN') ?? '2y'; + } + getFrontAuthCallbackUrl(): string { return ( this.configService.get('FRONT_AUTH_CALLBACK_URL') ?? diff --git a/server/src/integrations/environment/environment.validation.ts b/server/src/integrations/environment/environment.validation.ts index 5a1135092..05a02ae92 100644 --- a/server/src/integrations/environment/environment.validation.ts +++ b/server/src/integrations/environment/environment.validation.ts @@ -82,6 +82,8 @@ export class EnvironmentVariables { @IsString() LOGIN_TOKEN_SECRET: string; + @IsString() + API_TOKEN_SECRET: string; @IsDuration() @IsOptional() LOGIN_TOKEN_EXPIRES_IN: string; diff --git a/server/src/utils/prisma-select/model-select-map.ts b/server/src/utils/prisma-select/model-select-map.ts index 274847800..f61cf2a91 100644 --- a/server/src/utils/prisma-select/model-select-map.ts +++ b/server/src/utils/prisma-select/model-select-map.ts @@ -21,4 +21,5 @@ export type ModelSelectMap = { ViewFilter: Prisma.ViewFilterSelect; ViewSort: Prisma.ViewSortSelect; ViewField: Prisma.ViewFieldSelect; + ApiKey: Prisma.ApiKeySelect; };