feat: v2 managed organizations (#19341)
* refactor: allow non unique PlatformBilling customerId * feat: add PlatformBilling managed organizations fields * feat: ManagedOrganization table * refactor: bill overdue based on teamId * refactor: restructure organizations module * wip: organizations endpoints * Revert "wip: organizations endpoints" This reverts commit 0e9e66fc74f31da436f12930c1b6597c650ed621. * refactor: unique index for managed organizations * wip: organizations endpoints * wip: create managed organization * remove unecessary membership check because we have guard * feat: create managed org * feat: get managed org and orgs * feat: update and delete managed orgs * wip: api key logic * feat: allow variable api key length * feat: refresh managed org api key * feat: create managed org OAuth clients using api key * finish merge main * chore: bump platform libraries * tests: fix and add more tests to api-auth.strategy.e2e * refactor: dont request managed org slug and handle metadata as object * revert: billing service and repository update overdue based on sub and customer ids * refactor: v2 OAuth client permissions (#19501) * refactor: v2 permissions as string array * refactor: frontend work with permissions as string * tests: test '*' permissions * fix:managed org creator have profile & test can fetch oauth client * fix: tests * fix: OAuthClientCard on frontend * fix: tests
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getApiKeyFromHash(hashedKey: string) {
|
||||
return this.dbRead.prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamApiKeys(teamId: number) {
|
||||
return this.dbRead.prisma.apiKey.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteById(id: string) {
|
||||
return this.dbWrite.prisma.apiKey.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { ApiKeysController } from "@/modules/api-keys/controllers/api-keys.controller";
|
||||
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [ApiKeysRepository, ApiKeysService],
|
||||
controllers: [ApiKeysController],
|
||||
exports: [ApiKeysRepository, ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
|
||||
import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
|
||||
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { ApiAuthGuardRequest } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import { ApiTags, ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/api-keys",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiTags("Api Keys")
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Refresh API Key",
|
||||
description: `Generate a new API key and delete the current one. Provide API key to refresh as a Bearer token in the Authorization header (e.g. "Authorization: Bearer <apiKey>").`,
|
||||
})
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description: "Bearer <apiKey>",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
})
|
||||
@Post("/refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(
|
||||
@Body() body: RefreshApiKeyInput,
|
||||
@GetUser("id") userId: number,
|
||||
@Req() request: ApiAuthGuardRequest
|
||||
): Promise<RefreshApiKeyOutput> {
|
||||
const currentApiKey = await this.apiKeysService.getRequestApiKey(request);
|
||||
const newApiKey = await this.apiKeysService.refreshApiKey(userId, currentApiKey, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
apiKey: newApiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsInt, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CreateApiKeyInput extends RefreshApiKeyInput {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@ApiPropertyOptional({
|
||||
description: "For which team or organization is the api key.",
|
||||
})
|
||||
readonly teamId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
description: "Store additional note about this api key.",
|
||||
example: "This is an api key for development purposes.",
|
||||
})
|
||||
readonly note?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsInt, IsOptional, Min } from "class-validator";
|
||||
|
||||
export class RefreshApiKeyInput {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@ApiPropertyOptional({
|
||||
description: "For how many days is managed organization api key valid. Defaults to 30 days.",
|
||||
example: 60,
|
||||
default: 30,
|
||||
minimum: 1,
|
||||
})
|
||||
readonly apiKeyDaysValid?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@ApiPropertyOptional({ description: "If true, organization api key never expires.", example: true })
|
||||
readonly apiKeyNeverExpires?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class ApiKeyOutput {
|
||||
@IsString()
|
||||
@Expose()
|
||||
@DocsProperty()
|
||||
readonly apiKey!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiKeyOutput } from "@/modules/api-keys/outputs/api-key.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class RefreshApiKeyOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({ type: ApiKeyOutput })
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => ApiKeyOutput)
|
||||
data!: ApiKeyOutput;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
|
||||
import { AuthMethods } from "@/lib/enums/auth-methods";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
|
||||
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
|
||||
import { ApiAuthGuardRequest } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { createApiKeyHandler } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
constructor(
|
||||
private readonly apiKeysRepository: ApiKeysRepository,
|
||||
private readonly config: ConfigService
|
||||
) {}
|
||||
|
||||
async getRequestApiKey(request: ApiAuthGuardRequest) {
|
||||
if (request.authMethod !== AuthMethods.API_KEY) {
|
||||
throw new UnauthorizedException(
|
||||
"ApiKeysService - This endpoint can only be accessed using an API key by providing 'Authorization: Bearer <apiKey>' header"
|
||||
);
|
||||
}
|
||||
const apiKey = request.get("Authorization")?.replace("Bearer ", "");
|
||||
if (!apiKey) {
|
||||
throw new UnauthorizedException("ApiKeysService - No API key provided");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async createApiKey(authUserId: number, createApiKeyInput: CreateApiKeyInput) {
|
||||
if (createApiKeyInput.apiKeyDaysValid && createApiKeyInput.apiKeyNeverExpires) {
|
||||
throw new BadRequestException(
|
||||
"ApiKeysService -Cannot set both apiKeyDaysValid and apiKeyNeverExpires. It has to be either or none of them."
|
||||
);
|
||||
}
|
||||
|
||||
const defaultApiKeyDaysValid = 30;
|
||||
const apiKeyExpiresAfterDays = createApiKeyInput.apiKeyDaysValid
|
||||
? createApiKeyInput.apiKeyDaysValid
|
||||
: defaultApiKeyDaysValid;
|
||||
const apiKeyExpiresAt = DateTime.utc().plus({ days: apiKeyExpiresAfterDays }).toJSDate();
|
||||
const apiKey = await createApiKeyHandler({
|
||||
ctx: {
|
||||
user: {
|
||||
id: authUserId,
|
||||
},
|
||||
},
|
||||
input: {
|
||||
note: createApiKeyInput.note,
|
||||
neverExpires: !!createApiKeyInput.apiKeyNeverExpires,
|
||||
expiresAt: apiKeyExpiresAt,
|
||||
teamId: createApiKeyInput.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
|
||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
|
||||
if (!apiKeyInDb) {
|
||||
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
|
||||
}
|
||||
|
||||
const newApiKey = await this.createApiKey(authUserId, {
|
||||
...refreshApiKeyInput,
|
||||
note: apiKeyInDb.note || undefined,
|
||||
teamId: apiKeyInDb.teamId || undefined,
|
||||
});
|
||||
|
||||
await this.apiKeysRepository.deleteById(apiKeyInDb.id);
|
||||
|
||||
return newApiKey;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user