Skip to content

Commit

Permalink
Merge pull request #446 from bandada-infra/feat/apikeys
Browse files Browse the repository at this point in the history
Move API key logic from `GroupsService` to `AdminsService`
  • Loading branch information
vplasencia authored Mar 29, 2024
2 parents 92e5466 + 57fabe0 commit c1b12b9
Show file tree
Hide file tree
Showing 39 changed files with 3,220 additions and 467 deletions.
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@nestjs/testing": "^9.0.0",
"@types/express": "^4.17.13",
"@types/node": "18.11.18",
"@types/uuid": "^9.0.8",
"rimraf": "^5.0.1",
"ts-node": "^10.0.0",
"typescript": "^4.7.4"
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/app/admins/admins.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common"
import { ApiCreatedResponse } from "@nestjs/swagger"
import { CreateAdminDTO } from "./dto/create-admin.dto"
import { AdminsService } from "./admins.service"
import { Admin } from "./entities/admin.entity"
import { UpdateApiKeyDTO } from "./dto/update-apikey.dto"

@Controller("admins")
export class AdminsController {
constructor(private readonly adminsService: AdminsService) {}

@Post()
async createAdmin(@Body() dto: CreateAdminDTO): Promise<Admin> {
return this.adminsService.create(dto)
}

@Get(":admin")
@ApiCreatedResponse({ type: Admin })
async getAdmin(@Param("admin") adminId: string) {
return this.adminsService.findOne({ id: adminId })
}

@Put(":admin/apikey")
async updateApiKey(
@Param("admin") adminId: string,
@Body() dto: UpdateApiKeyDTO
): Promise<string> {
return this.adminsService.updateApiKey(adminId, dto.action)
}
}
9 changes: 5 additions & 4 deletions apps/api/src/app/admins/admins.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Global, Module } from "@nestjs/common"
import { TypeOrmModule } from "@nestjs/typeorm"
import { Admin } from "./entities/admin.entity"
import { AdminService } from "./admins.service"
import { AdminsService } from "./admins.service"
import { AdminsController } from "./admins.controller"

@Global()
@Module({
imports: [TypeOrmModule.forFeature([Admin])],
exports: [AdminService],
providers: [AdminService],
controllers: []
exports: [AdminsService],
providers: [AdminsService],
controllers: [AdminsController]
})
export class AdminsModule {}
175 changes: 175 additions & 0 deletions apps/api/src/app/admins/admins.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { id as idToHash } from "@ethersproject/hash"
import { ScheduleModule } from "@nestjs/schedule"
import { Test } from "@nestjs/testing"
import { TypeOrmModule } from "@nestjs/typeorm"
import { ApiKeyActions } from "@bandada/utils"
import { AdminsService } from "./admins.service"
import { Admin } from "./entities/admin.entity"

describe("AdminsService", () => {
const id = "1"
const hashedId = idToHash(id)
const address = "0x000000"
let admin: Admin
let adminsService: AdminsService

beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
TypeOrmModule.forRootAsync({
useFactory: () => ({
type: "sqlite",
database: ":memory:",
dropSchema: true,
entities: [Admin],
synchronize: true
})
}),
TypeOrmModule.forFeature([Admin]),
ScheduleModule.forRoot()
],
providers: [AdminsService]
}).compile()
adminsService = await module.resolve(AdminsService)
})

describe("# create", () => {
it("Should create an admin", async () => {
admin = await adminsService.create({ id, address })

expect(admin.id).toBe(idToHash(id))
expect(admin.address).toBe(address)
expect(admin.username).toBe(address.slice(-5))
expect(admin.apiEnabled).toBeFalsy()
expect(admin.apiKey).toBeNull()
})

it("Should create an admin given the username", async () => {
const id2 = "2"
const address2 = "0x000002"
const username = "admn2"

const admin = await adminsService.create({
id: id2,
address: address2,
username
})

expect(admin.id).toBe(idToHash(id2))
expect(admin.address).toBe(address2)
expect(admin.username).toBe(username)
expect(admin.apiEnabled).toBeFalsy()
expect(admin.apiKey).toBeNull()
})
})

describe("# findOne", () => {
it("Should return the admin given the identifier", async () => {
const found = await adminsService.findOne({ id: hashedId })

expect(found.id).toBe(admin.id)
expect(found.address).toBe(admin.address)
expect(found.username).toBe(admin.username)
expect(found.apiEnabled).toBeFalsy()
expect(found.apiKey).toBe(admin.apiKey)
})

it("Should return null if the given identifier does not belong to an admin", async () => {
expect(await adminsService.findOne({ id: "3" })).toBeNull()
})
})

describe("# updateApiKey", () => {
it("Should create an apikey for the admin", async () => {
const apiKey = await adminsService.updateApiKey(
admin.id,
ApiKeyActions.Generate
)

admin = await adminsService.findOne({ id: hashedId })

expect(admin.apiEnabled).toBeTruthy()
expect(admin.apiKey).toBe(apiKey)
})

it("Should generate another apikey for the admin", async () => {
const previousApiKey = admin.apiKey

const apiKey = await adminsService.updateApiKey(
admin.id,
ApiKeyActions.Generate
)

admin = await adminsService.findOne({ id: hashedId })

expect(admin.apiEnabled).toBeTruthy()
expect(admin.apiKey).toBe(apiKey)
expect(admin.apiKey).not.toBe(previousApiKey)
})

it("Should disable the apikey for the admin", async () => {
const { apiKey } = admin

await adminsService.updateApiKey(hashedId, ApiKeyActions.Disable)

admin = await adminsService.findOne({ id: hashedId })

expect(admin.apiEnabled).toBeFalsy()
expect(admin.apiKey).toBe(apiKey)
})

it("Should enable the apikey for the admin", async () => {
const { apiKey } = admin

await adminsService.updateApiKey(hashedId, ApiKeyActions.Enable)

admin = await adminsService.findOne({ id: hashedId })

expect(admin.apiEnabled).toBeTruthy()
expect(admin.apiKey).toBe(apiKey)
})

it("Should not create the apikey when the given id does not belog to an admin", async () => {
const wrongId = "wrongId"

const fun = adminsService.updateApiKey(
wrongId,
ApiKeyActions.Disable
)

await expect(fun).rejects.toThrow(
`The '${wrongId}' does not belong to an admin`
)
})

it("Should not enable the apikey before creation", async () => {
const tempAdmin = await adminsService.create({
id: "id2",
address: "address2"
})

const fun = adminsService.updateApiKey(
tempAdmin.id,
ApiKeyActions.Enable
)

await expect(fun).rejects.toThrow(
`The '${tempAdmin.id}' does not have an apikey`
)
})

it("Shoul throw if the action does not exist", async () => {
const wrongAction = "wrong-action"

const fun = adminsService.updateApiKey(
hashedId,
// @ts-ignore
wrongAction
)

await expect(fun).rejects.toThrow(
`Unsupported ${wrongAction} apikey`
)
})
})
})
56 changes: 54 additions & 2 deletions apps/api/src/app/admins/admins.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/* istanbul ignore file */
import { id } from "@ethersproject/hash"
import { Injectable } from "@nestjs/common"
import { BadRequestException, Injectable, Logger } from "@nestjs/common"
import { InjectRepository } from "@nestjs/typeorm"
import { FindOptionsWhere, Repository } from "typeorm"
import { v4 } from "uuid"
import { ApiKeyActions } from "@bandada/utils"
import { CreateAdminDTO } from "./dto/create-admin.dto"
import { Admin } from "./entities/admin.entity"

@Injectable()
export class AdminService {
export class AdminsService {
constructor(
@InjectRepository(Admin)
private readonly adminRepository: Repository<Admin>
Expand All @@ -29,4 +31,54 @@ export class AdminService {
): Promise<Admin> {
return this.adminRepository.findOneBy(payload)
}

/**
* Updates the API key for a given admin based on the specified actions.
*
* @param adminId The identifier of the admin.
* @param action The action to be executed on the API key of the admin.
* @returns {Promise<string>} The API key of the admin after the update operation. If the API key is disabled, the return value might not be meaningful.
* @throws {BadRequestException} If the admin ID does not correspond to an existing admin, if the admin does not have an API key when trying to enable it, or if the action is unsupported.
*/
async updateApiKey(
adminId: string,
action: ApiKeyActions
): Promise<string> {
const admin = await this.findOne({
id: adminId
})

if (!admin) {
throw new BadRequestException(
`The '${adminId}' does not belong to an admin`
)
}

switch (action) {
case ApiKeyActions.Generate:
admin.apiKey = v4()
admin.apiEnabled = true
break
case ApiKeyActions.Enable:
if (!admin.apiKey)
throw new BadRequestException(
`The '${adminId}' does not have an apikey`
)
admin.apiEnabled = true
break
case ApiKeyActions.Disable:
admin.apiEnabled = false
break
default:
throw new BadRequestException(`Unsupported ${action} apikey`)
}

await this.adminRepository.save(admin)

Logger.log(
`AdminsService: admin '${admin.id}' api key have been updated`
)

return admin.apiKey
}
}
7 changes: 7 additions & 0 deletions apps/api/src/app/admins/dto/update-apikey.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ApiKeyActions } from "@bandada/utils"
import { IsEnum } from "class-validator"

export class UpdateApiKeyDTO {
@IsEnum(ApiKeyActions)
action: ApiKeyActions
}
19 changes: 18 additions & 1 deletion apps/api/src/app/admins/entities/admin.entity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from "typeorm"
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn
} from "typeorm"

@Entity("admins")
@Index(["apiKey"], { unique: true })
export class Admin {
@PrimaryColumn({ unique: true })
id: string
Expand All @@ -12,6 +20,15 @@ export class Admin {
@Column({ unique: true })
username: string

@Column({ name: "api_key", nullable: true })
apiKey: string

@Column({ name: "api_enabled", default: false })
apiEnabled: boolean

@CreateDateColumn({ name: "created_at" })
createdAt: Date

@UpdateDateColumn({ name: "updated_at" })
updatedAt: Date
}
6 changes: 3 additions & 3 deletions apps/api/src/app/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import {
Injectable,
UnauthorizedException
} from "@nestjs/common"
import { AdminService } from "../admins/admins.service"
import { AdminsService } from "../admins/admins.service"

@Injectable()
export class AuthGuard implements CanActivate {
constructor(private adminService: AdminService) {}
constructor(private adminsService: AdminsService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest()
Expand All @@ -21,7 +21,7 @@ export class AuthGuard implements CanActivate {
}

try {
const admin = await this.adminService.findOne({ id: adminId })
const admin = await this.adminsService.findOne({ id: adminId })

req["admin"] = admin
} catch {
Expand Down
Loading

0 comments on commit c1b12b9

Please sign in to comment.