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 <martmull@hotmail.com>
This commit is contained in:
martmull 2023-10-12 18:07:44 +02:00 committed by GitHub
parent 6b990c8501
commit 8fbad7d3ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 430 additions and 42 deletions

View File

@ -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

View File

@ -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
# FLEXIBLE_BACKEND_ENABLED=false

View File

@ -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
# STORAGE_LOCAL_PATH=.local-storage

View File

@ -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<AppAbility>(
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');

View File

@ -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 {}

View File

@ -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<ApiKeyArgs>();
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));
}
}

View File

@ -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

View File

@ -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 {}

View File

@ -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>(ApiKeyResolver);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
});

View File

@ -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<AuthToken> {
return await this.apiKeyService.generateApiKeyToken(
workspaceId,
args.data.name,
args.data.expiresAt,
);
}
@Mutation(() => ApiKey)
@UseGuards(AbilityGuard)
@CheckAbilities(UpdateApiKeyAbilityHandler)
async revokeOneApiKey(
@Args() args: DeleteOneApiKeyArgs,
): Promise<Partial<ApiKey>> {
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 },
});
}
}

View File

@ -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<AuthToken> {
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,
};
}
}

View File

@ -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<PassportUser> {
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 };
}

View File

@ -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 {}

View File

@ -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;

View File

@ -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);

View File

@ -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")
}

View File

@ -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;

View File

@ -69,10 +69,18 @@ export class EnvironmentService {
return this.configService.get<string>('LOGIN_TOKEN_SECRET')!;
}
getApiTokenSecret(): string {
return this.configService.get<string>('API_TOKEN_SECRET')!;
}
getLoginTokenExpiresIn(): string {
return this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN') ?? '15m';
}
getApiTokenExpiresIn(): string {
return this.configService.get<string>('API_TOKEN_EXPIRES_IN') ?? '2y';
}
getFrontAuthCallbackUrl(): string {
return (
this.configService.get<string>('FRONT_AUTH_CALLBACK_URL') ??

View File

@ -82,6 +82,8 @@ export class EnvironmentVariables {
@IsString()
LOGIN_TOKEN_SECRET: string;
@IsString()
API_TOKEN_SECRET: string;
@IsDuration()
@IsOptional()
LOGIN_TOKEN_EXPIRES_IN: string;

View File

@ -21,4 +21,5 @@ export type ModelSelectMap = {
ViewFilter: Prisma.ViewFilterSelect;
ViewSort: Prisma.ViewSortSelect;
ViewField: Prisma.ViewFieldSelect;
ApiKey: Prisma.ApiKeySelect;
};