mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-22 19:41:53 +03:00
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:
parent
6b990c8501
commit
8fbad7d3ba
@ -21,6 +21,8 @@ services:
|
|||||||
generateValue: true
|
generateValue: true
|
||||||
- key: LOGIN_TOKEN_SECRET
|
- key: LOGIN_TOKEN_SECRET
|
||||||
generateValue: true
|
generateValue: true
|
||||||
|
- key: API_TOKEN_SECRET
|
||||||
|
generateValue: true
|
||||||
- key: REFRESH_TOKEN_SECRET
|
- key: REFRESH_TOKEN_SECRET
|
||||||
generateValue: true
|
generateValue: true
|
||||||
- key: PG_DATABASE_URL
|
- key: PG_DATABASE_URL
|
||||||
|
@ -6,6 +6,7 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default?connection_limit
|
|||||||
FRONT_BASE_URL=http://localhost:3001
|
FRONT_BASE_URL=http://localhost:3001
|
||||||
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string
|
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string
|
||||||
LOGIN_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
|
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string
|
||||||
SIGN_IN_PREFILLED=true
|
SIGN_IN_PREFILLED=true
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ SIGN_IN_PREFILLED=true
|
|||||||
# DEBUG_MODE=true
|
# DEBUG_MODE=true
|
||||||
# ACCESS_TOKEN_EXPIRES_IN=30m
|
# ACCESS_TOKEN_EXPIRES_IN=30m
|
||||||
# LOGIN_TOKEN_EXPIRES_IN=15m
|
# LOGIN_TOKEN_EXPIRES_IN=15m
|
||||||
|
# API_TOKEN_EXPIRES_IN=2y
|
||||||
# REFRESH_TOKEN_EXPIRES_IN=90d
|
# REFRESH_TOKEN_EXPIRES_IN=90d
|
||||||
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
|
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
|
||||||
# AUTH_GOOGLE_ENABLED=false
|
# AUTH_GOOGLE_ENABLED=false
|
||||||
@ -25,4 +27,4 @@ SIGN_IN_PREFILLED=true
|
|||||||
# LOGGER_DRIVER=console
|
# LOGGER_DRIVER=console
|
||||||
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
||||||
# LOG_LEVEL=error,warn
|
# LOG_LEVEL=error,warn
|
||||||
# FLEXIBLE_BACKEND_ENABLED=false
|
# FLEXIBLE_BACKEND_ENABLED=false
|
||||||
|
@ -6,9 +6,10 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test?connection_limit=1
|
|||||||
# the URL of the front-end app
|
# the URL of the front-end app
|
||||||
FRONT_BASE_URL=http://localhost:3001
|
FRONT_BASE_URL=http://localhost:3001
|
||||||
# random keys used to generate JWT tokens
|
# random keys used to generate JWT tokens
|
||||||
ACCESS_TOKEN_SECRET=secret_jwt
|
ACCESS_TOKEN_SECRET=secret_jwt
|
||||||
LOGIN_TOKEN_SECRET=secret_login_tokens
|
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 ————————
|
# ———————— Optional ————————
|
||||||
@ -20,4 +21,4 @@ REFRESH_TOKEN_SECRET=secret_refresh_token
|
|||||||
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
|
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
|
||||||
# AUTH_GOOGLE_ENABLED=false
|
# AUTH_GOOGLE_ENABLED=false
|
||||||
# STORAGE_TYPE=local
|
# STORAGE_TYPE=local
|
||||||
# STORAGE_LOCAL_PATH=.local-storage
|
# STORAGE_LOCAL_PATH=.local-storage
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
ActivityTarget,
|
ActivityTarget,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
ApiKey,
|
||||||
Comment,
|
Comment,
|
||||||
Company,
|
Company,
|
||||||
Favorite,
|
Favorite,
|
||||||
@ -30,6 +31,7 @@ type SubjectsAbility = Subjects<{
|
|||||||
Activity: Activity;
|
Activity: Activity;
|
||||||
ActivityTarget: ActivityTarget;
|
ActivityTarget: ActivityTarget;
|
||||||
Attachment: Attachment;
|
Attachment: Attachment;
|
||||||
|
ApiKey: ApiKey;
|
||||||
Comment: Comment;
|
Comment: Comment;
|
||||||
Company: Company;
|
Company: Company;
|
||||||
Favorite: Favorite;
|
Favorite: Favorite;
|
||||||
@ -55,7 +57,7 @@ export type AppAbility = PureAbility<
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AbilityFactory {
|
export class AbilityFactory {
|
||||||
defineAbility(user: User, workspace: Workspace) {
|
defineAbility(workspace: Workspace, user?: User) {
|
||||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
|
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
|
||||||
createPrismaAbility,
|
createPrismaAbility,
|
||||||
);
|
);
|
||||||
@ -66,8 +68,18 @@ export class AbilityFactory {
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
can(AbilityAction.Update, 'User', { id: user.id });
|
if (user) {
|
||||||
can(AbilityAction.Delete, 'User', { id: user.id });
|
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
|
// Workspace
|
||||||
can(AbilityAction.Read, 'Workspace');
|
can(AbilityAction.Read, 'Workspace');
|
||||||
@ -76,12 +88,19 @@ export class AbilityFactory {
|
|||||||
|
|
||||||
// Workspace Member
|
// Workspace Member
|
||||||
can(AbilityAction.Read, 'WorkspaceMember', { workspaceId: workspace.id });
|
can(AbilityAction.Read, 'WorkspaceMember', { workspaceId: workspace.id });
|
||||||
can(AbilityAction.Delete, 'WorkspaceMember', { workspaceId: workspace.id });
|
if (user) {
|
||||||
cannot(AbilityAction.Delete, 'WorkspaceMember', { userId: user.id });
|
can(AbilityAction.Delete, 'WorkspaceMember', {
|
||||||
can(AbilityAction.Update, 'WorkspaceMember', {
|
workspaceId: workspace.id,
|
||||||
userId: user.id,
|
});
|
||||||
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
|
// Company
|
||||||
can(AbilityAction.Read, 'Company', { workspaceId: workspace.id });
|
can(AbilityAction.Read, 'Company', { workspaceId: workspace.id });
|
||||||
@ -107,14 +126,19 @@ export class AbilityFactory {
|
|||||||
// Comment
|
// Comment
|
||||||
can(AbilityAction.Read, 'Comment', { workspaceId: workspace.id });
|
can(AbilityAction.Read, 'Comment', { workspaceId: workspace.id });
|
||||||
can(AbilityAction.Create, 'Comment');
|
can(AbilityAction.Create, 'Comment');
|
||||||
can(AbilityAction.Update, 'Comment', {
|
if (user) {
|
||||||
workspaceId: workspace.id,
|
can(AbilityAction.Update, 'Comment', {
|
||||||
authorId: user.id,
|
workspaceId: workspace.id,
|
||||||
});
|
authorId: user.id,
|
||||||
can(AbilityAction.Delete, 'Comment', {
|
});
|
||||||
workspaceId: workspace.id,
|
can(AbilityAction.Delete, 'Comment', {
|
||||||
authorId: user.id,
|
workspaceId: workspace.id,
|
||||||
});
|
authorId: user.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cannot(AbilityAction.Update, 'Comment');
|
||||||
|
cannot(AbilityAction.Delete, 'Comment');
|
||||||
|
}
|
||||||
|
|
||||||
// ActivityTarget
|
// ActivityTarget
|
||||||
can(AbilityAction.Read, 'ActivityTarget');
|
can(AbilityAction.Read, 'ActivityTarget');
|
||||||
|
@ -122,6 +122,12 @@ import {
|
|||||||
ReadViewFilterAbilityHandler,
|
ReadViewFilterAbilityHandler,
|
||||||
UpdateViewFilterAbilityHandler,
|
UpdateViewFilterAbilityHandler,
|
||||||
} from './handlers/view-filter.ability-handler';
|
} from './handlers/view-filter.ability-handler';
|
||||||
|
import {
|
||||||
|
CreateApiKeyAbilityHandler,
|
||||||
|
UpdateApiKeyAbilityHandler,
|
||||||
|
ManageApiKeyAbilityHandler,
|
||||||
|
ReadApiKeyAbilityHandler,
|
||||||
|
} from './handlers/api-key.ability-handler';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@ -229,6 +235,11 @@ import {
|
|||||||
CreateViewSortAbilityHandler,
|
CreateViewSortAbilityHandler,
|
||||||
UpdateViewSortAbilityHandler,
|
UpdateViewSortAbilityHandler,
|
||||||
DeleteViewSortAbilityHandler,
|
DeleteViewSortAbilityHandler,
|
||||||
|
// ApiKey
|
||||||
|
ReadApiKeyAbilityHandler,
|
||||||
|
ManageApiKeyAbilityHandler,
|
||||||
|
CreateApiKeyAbilityHandler,
|
||||||
|
UpdateApiKeyAbilityHandler,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AbilityFactory,
|
AbilityFactory,
|
||||||
@ -333,6 +344,11 @@ import {
|
|||||||
CreateViewSortAbilityHandler,
|
CreateViewSortAbilityHandler,
|
||||||
UpdateViewSortAbilityHandler,
|
UpdateViewSortAbilityHandler,
|
||||||
DeleteViewSortAbilityHandler,
|
DeleteViewSortAbilityHandler,
|
||||||
|
// ApiKey
|
||||||
|
ReadApiKeyAbilityHandler,
|
||||||
|
ManageApiKeyAbilityHandler,
|
||||||
|
CreateApiKeyAbilityHandler,
|
||||||
|
UpdateApiKeyAbilityHandler,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AbilityModule {}
|
export class AbilityModule {}
|
||||||
|
85
server/src/ability/handlers/api-key.ability-handler.ts
Normal file
85
server/src/ability/handlers/api-key.ability-handler.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
@ -74,11 +74,7 @@ import { ExceptionFilter } from './filters/exception.filter';
|
|||||||
decoded as JwtPayload,
|
decoded as JwtPayload,
|
||||||
);
|
);
|
||||||
|
|
||||||
const conditionalSchema = await tenantService.createTenantSchema(
|
return await tenantService.createTenantSchema(workspace.id);
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return conditionalSchema;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JsonWebTokenError) {
|
if (error instanceof JsonWebTokenError) {
|
||||||
//mockedUserJWT
|
//mockedUserJWT
|
||||||
|
12
server/src/core/api-key/api-key.module.ts
Normal file
12
server/src/core/api-key/api-key.module.ts
Normal 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 {}
|
28
server/src/core/api-key/api-key.resolver.spec.ts
Normal file
28
server/src/core/api-key/api-key.resolver.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
82
server/src/core/api-key/api-key.resolver.ts
Normal file
82
server/src/core/api-key/api-key.resolver.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
63
server/src/core/api-key/api-key.service.ts
Normal file
63
server/src/core/api-key/api-key.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,14 +1,19 @@
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
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 { Strategy, ExtractJwt } from 'passport-jwt';
|
||||||
import { User, Workspace } from '@prisma/client';
|
import { User, Workspace } from '@prisma/client';
|
||||||
|
|
||||||
import { PrismaService } from 'src/database/prisma.service';
|
import { PrismaService } from 'src/database/prisma.service';
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
import { assert } from 'src/utils/assert';
|
||||||
|
|
||||||
export type JwtPayload = { sub: string; workspaceId: string };
|
export type JwtPayload = { sub: string; workspaceId: string; jti?: string };
|
||||||
export type PassportUser = { user: User; workspace: Workspace };
|
export type PassportUser = { user?: User; workspace: Workspace };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
@ -24,22 +29,25 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: JwtPayload): Promise<PassportUser> {
|
async validate(payload: JwtPayload): Promise<PassportUser> {
|
||||||
const user = await this.prismaService.client.user.findUniqueOrThrow({
|
const workspace = await this.prismaService.client.workspace.findUnique({
|
||||||
where: { id: payload.sub },
|
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) {
|
if (!workspace) {
|
||||||
throw new UnauthorizedException();
|
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 };
|
return { user, workspace };
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import { AttachmentModule } from './attachment/attachment.module';
|
|||||||
import { ActivityModule } from './activity/activity.module';
|
import { ActivityModule } from './activity/activity.module';
|
||||||
import { ViewModule } from './view/view.module';
|
import { ViewModule } from './view/view.module';
|
||||||
import { FavoriteModule } from './favorite/favorite.module';
|
import { FavoriteModule } from './favorite/favorite.module';
|
||||||
|
import { ApiKeyModule } from './api-key/api-key.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -31,6 +32,7 @@ import { FavoriteModule } from './favorite/favorite.module';
|
|||||||
ActivityModule,
|
ActivityModule,
|
||||||
ViewModule,
|
ViewModule,
|
||||||
FavoriteModule,
|
FavoriteModule,
|
||||||
|
ApiKeyModule,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@ -43,6 +45,7 @@ import { FavoriteModule } from './favorite/favorite.module';
|
|||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
AttachmentModule,
|
AttachmentModule,
|
||||||
FavoriteModule,
|
FavoriteModule,
|
||||||
|
ApiKeyModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule {}
|
export class CoreModule {}
|
||||||
|
@ -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;
|
@ -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);
|
@ -178,6 +178,7 @@ model Workspace {
|
|||||||
viewFilters ViewFilter[]
|
viewFilters ViewFilter[]
|
||||||
views View[]
|
views View[]
|
||||||
viewSorts ViewSort[]
|
viewSorts ViewSort[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
/// @TypeGraphQL.omit(input: true, output: true)
|
/// @TypeGraphQL.omit(input: true, output: true)
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
@ -886,3 +887,23 @@ model ViewField {
|
|||||||
@@id([viewId, key])
|
@@id([viewId, key])
|
||||||
@@map("viewFields")
|
@@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")
|
||||||
|
}
|
||||||
|
@ -35,8 +35,8 @@ export class AbilityGuard implements CanActivate {
|
|||||||
assert(passportUser, '', UnauthorizedException);
|
assert(passportUser, '', UnauthorizedException);
|
||||||
|
|
||||||
const ability = this.abilityFactory.defineAbility(
|
const ability = this.abilityFactory.defineAbility(
|
||||||
passportUser.user,
|
|
||||||
passportUser.workspace,
|
passportUser.workspace,
|
||||||
|
passportUser.user,
|
||||||
);
|
);
|
||||||
|
|
||||||
request.ability = ability;
|
request.ability = ability;
|
||||||
|
@ -69,10 +69,18 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('LOGIN_TOKEN_SECRET')!;
|
return this.configService.get<string>('LOGIN_TOKEN_SECRET')!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApiTokenSecret(): string {
|
||||||
|
return this.configService.get<string>('API_TOKEN_SECRET')!;
|
||||||
|
}
|
||||||
|
|
||||||
getLoginTokenExpiresIn(): string {
|
getLoginTokenExpiresIn(): string {
|
||||||
return this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN') ?? '15m';
|
return this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN') ?? '15m';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApiTokenExpiresIn(): string {
|
||||||
|
return this.configService.get<string>('API_TOKEN_EXPIRES_IN') ?? '2y';
|
||||||
|
}
|
||||||
|
|
||||||
getFrontAuthCallbackUrl(): string {
|
getFrontAuthCallbackUrl(): string {
|
||||||
return (
|
return (
|
||||||
this.configService.get<string>('FRONT_AUTH_CALLBACK_URL') ??
|
this.configService.get<string>('FRONT_AUTH_CALLBACK_URL') ??
|
||||||
|
@ -82,6 +82,8 @@ export class EnvironmentVariables {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
LOGIN_TOKEN_SECRET: string;
|
LOGIN_TOKEN_SECRET: string;
|
||||||
|
@IsString()
|
||||||
|
API_TOKEN_SECRET: string;
|
||||||
@IsDuration()
|
@IsDuration()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
LOGIN_TOKEN_EXPIRES_IN: string;
|
LOGIN_TOKEN_EXPIRES_IN: string;
|
||||||
|
@ -21,4 +21,5 @@ export type ModelSelectMap = {
|
|||||||
ViewFilter: Prisma.ViewFilterSelect;
|
ViewFilter: Prisma.ViewFilterSelect;
|
||||||
ViewSort: Prisma.ViewSortSelect;
|
ViewSort: Prisma.ViewSortSelect;
|
||||||
ViewField: Prisma.ViewFieldSelect;
|
ViewField: Prisma.ViewFieldSelect;
|
||||||
|
ApiKey: Prisma.ApiKeySelect;
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user