refactor: move apikey logic from group to admin

This commit is contained in:
Jeeiii
2024-03-21 23:58:13 +01:00
parent 496bbbb532
commit 784929c897
23 changed files with 510 additions and 210 deletions

View File

@@ -0,0 +1,23 @@
import { Body, Controller, Post, Put } from "@nestjs/common"
import { CreateAdminDTO } from "./dto/create-admin.dto"
import { UpdateApiKeyDTO } from "./dto/update-apikey.dto"
import { AdminsService } from "./admins.service"
import { Admin } from "./entities/admin.entity"
@Controller("admins")
export class AdminsController {
constructor(private readonly adminsService: AdminsService) {}
@Post()
async createAdmin(@Body() dto: CreateAdminDTO): Promise<Admin> {
return this.adminsService.create(dto)
}
@Put("update-apikey")
async updateApiKey(@Body() dto: UpdateApiKeyDTO): Promise<string> {
return this.adminsService.updateApiKey({
adminId: dto.adminId,
action: dto.action
})
}
}

View File

@@ -1,13 +1,14 @@
import { Global, Module } from "@nestjs/common" import { Global, Module } from "@nestjs/common"
import { TypeOrmModule } from "@nestjs/typeorm" import { TypeOrmModule } from "@nestjs/typeorm"
import { Admin } from "./entities/admin.entity" import { Admin } from "./entities/admin.entity"
import { AdminService } from "./admins.service" import { AdminsService } from "./admins.service"
import { AdminsController } from "./admins.controller"
@Global() @Global()
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Admin])], imports: [TypeOrmModule.forFeature([Admin])],
exports: [AdminService], exports: [AdminsService],
providers: [AdminService], providers: [AdminsService],
controllers: [] controllers: [AdminsController]
}) })
export class AdminsModule {} export class AdminsModule {}

View File

@@ -0,0 +1,181 @@
import { id as idToHash } from "@ethersproject/hash"
import { ScheduleModule } from "@nestjs/schedule"
import { Test } from "@nestjs/testing"
import { TypeOrmModule } from "@nestjs/typeorm"
import { AdminsService } from "./admins.service"
import { Admin } from "./entities/admin.entity"
import { ApiKeyActions } from "../../types"
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({
adminId: hashedId,
action: 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({
adminId: hashedId,
action: 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({
adminId: hashedId,
action: 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({
adminId: hashedId,
action: 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({
adminId: wrongId,
action: 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({
adminId: tempAdmin.id,
action: 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({
adminId: hashedId,
// @ts-ignore
action: wrongAction
})
await expect(fun).rejects.toThrow(
`Unsupported ${wrongAction} apikey`
)
})
})
})

View File

@@ -1,17 +1,24 @@
/* istanbul ignore file */ /* istanbul ignore file */
import { id } from "@ethersproject/hash" import { id } from "@ethersproject/hash"
import { Injectable } from "@nestjs/common" import {
BadRequestException,
Injectable,
Logger
} from "@nestjs/common"
import { InjectRepository } from "@nestjs/typeorm" import { InjectRepository } from "@nestjs/typeorm"
import { FindOptionsWhere, Repository } from "typeorm" import { FindOptionsWhere, Repository } from "typeorm"
import { v4 } from "uuid"
import { CreateAdminDTO } from "./dto/create-admin.dto" import { CreateAdminDTO } from "./dto/create-admin.dto"
import { Admin } from "./entities/admin.entity" import { Admin } from "./entities/admin.entity"
import { UpdateApiKeyDTO } from "./dto/update-apikey.dto"
import { ApiKeyActions } from "../../types"
@Injectable() @Injectable()
export class AdminService { export class AdminsService {
constructor( constructor(
@InjectRepository(Admin) @InjectRepository(Admin)
private readonly adminRepository: Repository<Admin> private readonly adminRepository: Repository<Admin>
) {} ) { }
public async create(payload: CreateAdminDTO): Promise<Admin> { public async create(payload: CreateAdminDTO): Promise<Admin> {
const username = payload.username || payload.address.slice(-5) const username = payload.username || payload.address.slice(-5)
@@ -29,4 +36,50 @@ export class AdminService {
): Promise<Admin> { ): Promise<Admin> {
return this.adminRepository.findOneBy(payload) return this.adminRepository.findOneBy(payload)
} }
/**
* Updates the API key for a given admin based on the specified actions.
*
* @param {UpdateApiKeyDTO} updateApiKeyDTO The DTO containing the admin ID and the action to be performed.
* @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, action }: UpdateApiKeyDTO): 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
}
} }

View File

@@ -0,0 +1,10 @@
import { IsEnum, IsString } from "class-validator"
import { ApiKeyActions } from "../../../types"
export class UpdateApiKeyDTO {
@IsString()
adminId: string
@IsEnum(ApiKeyActions)
action: ApiKeyActions
}

View File

@@ -1,4 +1,10 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from "typeorm" import {
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn
} from "typeorm"
@Entity("admins") @Entity("admins")
export class Admin { export class Admin {
@@ -12,6 +18,15 @@ export class Admin {
@Column({ unique: true }) @Column({ unique: true })
username: string username: string
@Column({ name: "api_key", nullable: true })
apiKey: string
@Column({ name: "api_enabled", default: false })
apiEnabled: boolean
@CreateDateColumn({ name: "created_at" }) @CreateDateColumn({ name: "created_at" })
createdAt: Date createdAt: Date
@UpdateDateColumn({ name: "updated_at" })
updatedAt: Date
} }

View File

@@ -5,11 +5,11 @@ import {
Injectable, Injectable,
UnauthorizedException UnauthorizedException
} from "@nestjs/common" } from "@nestjs/common"
import { AdminService } from "../admins/admins.service" import { AdminsService } from "../admins/admins.service"
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private adminService: AdminService) {} constructor(private adminsService: AdminsService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest() const req = context.switchToHttp().getRequest()
@@ -21,7 +21,7 @@ export class AuthGuard implements CanActivate {
} }
try { try {
const admin = await this.adminService.findOne({ id: adminId }) const admin = await this.adminsService.findOne({ id: adminId })
req["admin"] = admin req["admin"] = admin
} catch { } catch {

View File

@@ -3,7 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"
import { ethers } from "ethers" import { ethers } from "ethers"
import { generateNonce, SiweMessage } from "siwe" import { generateNonce, SiweMessage } from "siwe"
import { Admin } from "../admins/entities/admin.entity" import { Admin } from "../admins/entities/admin.entity"
import { AdminService } from "../admins/admins.service" import { AdminsService } from "../admins/admins.service"
import { AuthService } from "./auth.service" import { AuthService } from "./auth.service"
jest.mock("@bandada/utils", () => ({ jest.mock("@bandada/utils", () => ({
@@ -47,7 +47,7 @@ function createSiweMessage(address: string, statement?: string) {
describe("AuthService", () => { describe("AuthService", () => {
let authService: AuthService let authService: AuthService
let adminService: AdminService let adminsService: AdminsService
let originalApiUrl: string let originalApiUrl: string
@@ -65,11 +65,11 @@ describe("AuthService", () => {
}), }),
TypeOrmModule.forFeature([Admin]) TypeOrmModule.forFeature([Admin])
], ],
providers: [AuthService, AdminService] providers: [AuthService, AdminsService]
}).compile() }).compile()
authService = await module.resolve(AuthService) authService = await module.resolve(AuthService)
adminService = await module.resolve(AdminService) adminsService = await module.resolve(AdminsService)
// Set API_URL so auth service can validate domain // Set API_URL so auth service can validate domain
originalApiUrl = process.env.DASHBOARD_URL originalApiUrl = process.env.DASHBOARD_URL
@@ -169,7 +169,7 @@ describe("AuthService", () => {
describe("# isLoggedIn", () => { describe("# isLoggedIn", () => {
it("Should return true if the admin exists", async () => { it("Should return true if the admin exists", async () => {
const admin = await adminService.findOne({ const admin = await adminsService.findOne({
address: account1.address address: account1.address
}) })

View File

@@ -5,12 +5,12 @@ import {
} from "@nestjs/common" } from "@nestjs/common"
import { SiweMessage } from "siwe" import { SiweMessage } from "siwe"
import { v4 } from "uuid" import { v4 } from "uuid"
import { AdminService } from "../admins/admins.service" import { AdminsService } from "../admins/admins.service"
import { SignInWithEthereumDTO } from "./dto/siwe.dto" import { SignInWithEthereumDTO } from "./dto/siwe.dto"
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor(private readonly adminService: AdminService) {} constructor(private readonly adminsService: AdminsService) {}
async signIn( async signIn(
{ message, signature }: SignInWithEthereumDTO, { message, signature }: SignInWithEthereumDTO,
@@ -37,10 +37,10 @@ export class AuthService {
) )
} }
let admin = await this.adminService.findOne({ address }) let admin = await this.adminsService.findOne({ address })
if (!admin) { if (!admin) {
admin = await this.adminService.create({ admin = await this.adminsService.create({
id: v4(), id: v4(),
address address
}) })
@@ -50,6 +50,6 @@ export class AuthService {
} }
async isLoggedIn(adminId: string): Promise<boolean> { async isLoggedIn(adminId: string): Promise<boolean> {
return !!(await this.adminService.findOne({ id: adminId })) return !!(await this.adminsService.findOne({ id: adminId }))
} }
} }

View File

@@ -5,12 +5,14 @@ import { GroupsModule } from "../groups/groups.module"
import { OAuthAccount } from "./entities/credentials-account.entity" import { OAuthAccount } from "./entities/credentials-account.entity"
import { CredentialsController } from "./credentials.controller" import { CredentialsController } from "./credentials.controller"
import { CredentialsService } from "./credentials.service" import { CredentialsService } from "./credentials.service"
import { AdminsModule } from "../admins/admins.module"
@Module({ @Module({
imports: [ imports: [
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
forwardRef(() => GroupsModule), forwardRef(() => GroupsModule),
TypeOrmModule.forFeature([OAuthAccount]) TypeOrmModule.forFeature([OAuthAccount]),
AdminsModule
], ],
controllers: [CredentialsController], controllers: [CredentialsController],
providers: [CredentialsService], providers: [CredentialsService],

View File

@@ -9,6 +9,7 @@ import { Invite } from "../invites/entities/invite.entity"
import { InvitesService } from "../invites/invites.service" import { InvitesService } from "../invites/invites.service"
import { OAuthAccount } from "./entities/credentials-account.entity" import { OAuthAccount } from "./entities/credentials-account.entity"
import { CredentialsService } from "./credentials.service" import { CredentialsService } from "./credentials.service"
import { AdminsModule } from "../admins/admins.module"
jest.mock("@bandada/utils", () => ({ jest.mock("@bandada/utils", () => ({
__esModule: true, __esModule: true,
@@ -59,7 +60,8 @@ describe("CredentialsService", () => {
}) })
}), }),
TypeOrmModule.forFeature([Group, Invite, Member, OAuthAccount]), TypeOrmModule.forFeature([Group, Invite, Member, OAuthAccount]),
ScheduleModule.forRoot() ScheduleModule.forRoot(),
AdminsModule
], ],
providers: [GroupsService, InvitesService, CredentialsService] providers: [GroupsService, InvitesService, CredentialsService]
}).compile() }).compile()

View File

@@ -1,5 +1,4 @@
import { import {
IsBoolean,
IsJSON, IsJSON,
IsNumber, IsNumber,
IsOptional, IsOptional,
@@ -21,10 +20,6 @@ export class UpdateGroupDto {
@Max(32) @Max(32)
readonly treeDepth?: number readonly treeDepth?: number
@IsOptional()
@IsBoolean()
readonly apiEnabled?: boolean
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
readonly fingerprintDuration?: number readonly fingerprintDuration?: number

View File

@@ -59,12 +59,6 @@ export class Group {
}) })
credentials: any // TODO: Add correct type for credentials JSON credentials: any // TODO: Add correct type for credentials JSON
@Column({ name: "api_enabled", default: false })
apiEnabled: boolean
@Column({ name: "api_key", nullable: true })
apiKey: string
@CreateDateColumn({ name: "created_at" }) @CreateDateColumn({ name: "created_at" })
createdAt: Date createdAt: Date

View File

@@ -21,7 +21,6 @@ import {
ApiQuery, ApiQuery,
ApiTags ApiTags
} from "@nestjs/swagger" } from "@nestjs/swagger"
import { ThrottlerGuard } from "@nestjs/throttler"
import { Request } from "express" import { Request } from "express"
import { AuthGuard } from "../auth/auth.guard" import { AuthGuard } from "../auth/auth.guard"
import { stringifyJSON } from "../utils" import { stringifyJSON } from "../utils"
@@ -56,14 +55,15 @@ export class GroupsController {
@Get(":group") @Get(":group")
@ApiOperation({ description: "Returns a specific group." }) @ApiOperation({ description: "Returns a specific group." })
@ApiCreatedResponse({ type: Group }) @ApiCreatedResponse({ type: Group })
async getGroup(@Param("group") groupId: string, @Req() req: Request) { async getGroup(
@Param("group") groupId: string
) {
const group = await this.groupsService.getGroup(groupId) const group = await this.groupsService.getGroup(groupId)
const fingerprint = await this.groupsService.getFingerprint(groupId) const fingerprint = await this.groupsService.getFingerprint(groupId)
return mapGroupToResponseDTO( return mapGroupToResponseDTO(
group, group,
fingerprint, fingerprint
req.session.adminId === group.adminId
) )
} }
@@ -79,8 +79,7 @@ export class GroupsController {
return mapGroupToResponseDTO( return mapGroupToResponseDTO(
group, group,
fingerprint, fingerprint
req.session.adminId === group.adminId
) )
} }
@@ -109,19 +108,10 @@ export class GroupsController {
return mapGroupToResponseDTO( return mapGroupToResponseDTO(
group, group,
fingerprint, fingerprint
req.session.adminId === group.adminId
) )
} }
@Patch(":group/api-key")
@UseGuards(AuthGuard)
@UseGuards(ThrottlerGuard)
@ApiExcludeEndpoint()
async updateApiKey(@Req() req: Request, @Param("group") groupId: string) {
return this.groupsService.updateApiKey(groupId, req.session.adminId)
}
@Get(":group/members/:member") @Get(":group/members/:member")
@ApiOperation({ @ApiOperation({
description: description:

View File

@@ -6,15 +6,19 @@ import { Group } from "./entities/group.entity"
import { Member } from "./entities/member.entity" import { Member } from "./entities/member.entity"
import { GroupsController } from "./groups.controller" import { GroupsController } from "./groups.controller"
import { GroupsService } from "./groups.service" import { GroupsService } from "./groups.service"
import { AdminsModule } from "../admins/admins.module"
import { Admin } from "../admins/entities/admin.entity"
import { AdminsService } from "../admins/admins.service"
@Module({ @Module({
imports: [ imports: [
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
forwardRef(() => InvitesModule), forwardRef(() => InvitesModule),
TypeOrmModule.forFeature([Member, Group]) TypeOrmModule.forFeature([Member, Group, Admin]),
AdminsModule
], ],
controllers: [GroupsController], controllers: [GroupsController],
providers: [GroupsService], providers: [GroupsService, AdminsService],
exports: [GroupsService] exports: [GroupsService]
}) })
export class GroupsModule {} export class GroupsModule {}

View File

@@ -7,6 +7,10 @@ import { OAuthAccount } from "../credentials/entities/credentials-account.entity
import { Group } from "./entities/group.entity" import { Group } from "./entities/group.entity"
import { Member } from "./entities/member.entity" import { Member } from "./entities/member.entity"
import { GroupsService } from "./groups.service" import { GroupsService } from "./groups.service"
import { AdminsService } from "../admins/admins.service"
import { AdminsModule } from "../admins/admins.module"
import { Admin } from "../admins/entities/admin.entity"
import { ApiKeyActions } from "../../types"
jest.mock("@bandada/utils", () => ({ jest.mock("@bandada/utils", () => ({
__esModule: true, __esModule: true,
@@ -23,6 +27,7 @@ jest.mock("@bandada/utils", () => ({
describe("GroupsService", () => { describe("GroupsService", () => {
let groupsService: GroupsService let groupsService: GroupsService
let invitesService: InvitesService let invitesService: InvitesService
let adminsService: AdminsService
let groupId: string let groupId: string
beforeAll(async () => { beforeAll(async () => {
@@ -33,18 +38,20 @@ describe("GroupsService", () => {
type: "sqlite", type: "sqlite",
database: ":memory:", database: ":memory:",
dropSchema: true, dropSchema: true,
entities: [Group, Invite, Member, OAuthAccount], entities: [Group, Invite, Member, OAuthAccount, Admin],
synchronize: true synchronize: true
}) })
}), }),
TypeOrmModule.forFeature([Group, Invite, Member]), TypeOrmModule.forFeature([Group, Invite, Member, Admin]),
ScheduleModule.forRoot() ScheduleModule.forRoot(),
AdminsModule
], ],
providers: [GroupsService, InvitesService] providers: [GroupsService, InvitesService, AdminsService]
}).compile() }).compile()
groupsService = await module.resolve(GroupsService) groupsService = await module.resolve(GroupsService)
invitesService = await module.resolve(InvitesService) invitesService = await module.resolve(InvitesService)
adminsService = await module.resolve(AdminsService)
await groupsService.initialize() await groupsService.initialize()
@@ -225,54 +232,6 @@ describe("GroupsService", () => {
}) })
}) })
describe("# updateApiKey", () => {
let group: Group
it("Should enable the API with a new API key", async () => {
group = await groupsService.createGroup(
{
name: "Group2",
description: "This is a new group",
treeDepth: 16,
fingerprintDuration: 3600
},
"admin"
)
await groupsService.updateGroup(
group.id,
{ apiEnabled: true },
"admin"
)
const { apiKey } = await groupsService.getGroup(group.id)
expect(apiKey).toHaveLength(36)
})
it("Should update the api key of the group", async () => {
const apiKey = await groupsService.updateApiKey(group.id, "admin")
expect(apiKey).toHaveLength(36)
})
it("Should not update the api key if the admin is the wrong one", async () => {
const fun = groupsService.updateApiKey(groupId, "wrong-admin")
await expect(fun).rejects.toThrow(
`You are not the admin of the group '${groupId}'`
)
})
it("Should not update the api key if the api is not enabled", async () => {
const fun = groupsService.updateApiKey(groupId, "admin")
await expect(fun).rejects.toThrow(
`Group '${groupId}' API key is not enabled`
)
})
})
describe("# addMember", () => { describe("# addMember", () => {
let invite: Invite let invite: Invite
@@ -424,10 +383,21 @@ describe("GroupsService", () => {
}) })
describe("# Add and remove member via API", () => { describe("# Add and remove member via API", () => {
let admin: Admin
let group: Group let group: Group
let apiKey: string let apiKey: string
beforeAll(async () => { beforeAll(async () => {
admin = await adminsService.create({
id: "admin",
address: "0x"
})
apiKey = await adminsService.updateApiKey({
adminId: admin.id,
action: ApiKeyActions.Generate
})
group = await groupsService.createGroup( group = await groupsService.createGroup(
{ {
name: "Group2", name: "Group2",
@@ -435,16 +405,10 @@ describe("GroupsService", () => {
treeDepth: 16, treeDepth: 16,
fingerprintDuration: 3600 fingerprintDuration: 3600
}, },
"admin" admin.id
) )
await groupsService.updateGroup( admin = await adminsService.findOne({ id: admin.id })
group.id,
{ apiEnabled: true },
"admin"
)
apiKey = (await groupsService.getGroup(group.id)).apiKey
}) })
it("Should add a member to an existing group via API", async () => { it("Should add a member to an existing group via API", async () => {
@@ -493,19 +457,66 @@ describe("GroupsService", () => {
) )
}) })
it("Should not add a member to an existing group if API is disabled", async () => { it("Should not add a member to an existing group if API belongs to another admin", async () => {
await groupsService.updateGroup(
group.id,
{ apiEnabled: false },
"admin"
)
const fun = groupsService.addMemberWithAPIKey( const fun = groupsService.addMemberWithAPIKey(
groupId, groupId,
"100002", "100002",
apiKey apiKey
) )
await expect(fun).rejects.toThrow(
`Invalid admin for group '${groupId}'`
)
})
it("Should not remove a member to an existing group if API belongs to another admin", async () => {
const fun = groupsService.removeMemberWithAPIKey(
groupId,
"100001",
apiKey
)
await expect(fun).rejects.toThrow(
`Invalid admin for group '${groupId}'`
)
})
it("Should not add a member to an existing group if API is invalid", async () => {
const fun = groupsService.addMemberWithAPIKey(
group.id,
"100002",
"apiKey"
)
await expect(fun).rejects.toThrow(
"Invalid API key or API access not enabled for group"
)
})
it("Should not remove a member to an existing group if API is invalid", async () => {
const fun = groupsService.removeMemberWithAPIKey(
group.id,
"100001",
"apiKey"
)
await expect(fun).rejects.toThrow(
"Invalid API key or API access not enabled for group"
)
})
it("Should not add a member to an existing group if API is disabled", async () => {
await adminsService.updateApiKey({
adminId: admin.id,
action: ApiKeyActions.Disable
})
const fun = groupsService.addMemberWithAPIKey(
group.id,
"100002",
apiKey
)
await expect(fun).rejects.toThrow( await expect(fun).rejects.toThrow(
"Invalid API key or API access not enabled for group" "Invalid API key or API access not enabled for group"
) )
@@ -513,7 +524,7 @@ describe("GroupsService", () => {
it("Should not remove a member to an existing group if API is disabled", async () => { it("Should not remove a member to an existing group if API is disabled", async () => {
const fun = groupsService.removeMemberWithAPIKey( const fun = groupsService.removeMemberWithAPIKey(
groupId, group.id,
"100001", "100001",
apiKey apiKey
) )
@@ -525,10 +536,21 @@ describe("GroupsService", () => {
}) })
describe("# Add and remove members via API", () => { describe("# Add and remove members via API", () => {
let admin: Admin
let group: Group let group: Group
let apiKey: string let apiKey: string
beforeAll(async () => { beforeAll(async () => {
admin = await adminsService.create({
id: "admin",
address: "0x"
})
apiKey = await adminsService.updateApiKey({
adminId: admin.id,
action: ApiKeyActions.Generate
})
group = await groupsService.createGroup( group = await groupsService.createGroup(
{ {
name: "Group2", name: "Group2",
@@ -536,16 +558,10 @@ describe("GroupsService", () => {
treeDepth: 16, treeDepth: 16,
fingerprintDuration: 3600 fingerprintDuration: 3600
}, },
"admin" admin.id
) )
await groupsService.updateGroup( admin = await adminsService.findOne({ id: admin.id })
group.id,
{ apiEnabled: true },
"admin"
)
apiKey = (await groupsService.getGroup(group.id)).apiKey
}) })
it("Should add a member to an existing group via API", async () => { it("Should add a member to an existing group via API", async () => {
@@ -600,19 +616,66 @@ describe("GroupsService", () => {
) )
}) })
it("Should not add a member to an existing group if API is disabled", async () => { it("Should not add a member to an existing group if API belongs to another admin", async () => {
await groupsService.updateGroup(
group.id,
{ apiEnabled: false },
"admin"
)
const fun = groupsService.addMembersWithAPIKey( const fun = groupsService.addMembersWithAPIKey(
groupId, groupId,
["100002"], ["100002"],
apiKey apiKey
) )
await expect(fun).rejects.toThrow(
`Invalid admin for group '${groupId}'`
)
})
it("Should not remove a member to an existing group if API belongs to another admin", async () => {
const fun = groupsService.removeMembersWithAPIKey(
groupId,
["100001"],
apiKey
)
await expect(fun).rejects.toThrow(
`Invalid admin for group '${groupId}'`
)
})
it("Should not add a member to an existing group if API is invalid", async () => {
const fun = groupsService.addMembersWithAPIKey(
group.id,
["100002"],
"apiKey"
)
await expect(fun).rejects.toThrow(
"Invalid API key or API access not enabled for group"
)
})
it("Should not remove a member to an existing group if API is invalid", async () => {
const fun = groupsService.removeMembersWithAPIKey(
group.id,
["100001"],
"apiKey"
)
await expect(fun).rejects.toThrow(
"Invalid API key or API access not enabled for group"
)
})
it("Should not add a member to an existing group if API is disabled", async () => {
await adminsService.updateApiKey({
adminId: admin.id,
action: ApiKeyActions.Disable
})
const fun = groupsService.addMembersWithAPIKey(
group.id,
["100002"],
apiKey
)
await expect(fun).rejects.toThrow( await expect(fun).rejects.toThrow(
"Invalid API key or API access not enabled for group" "Invalid API key or API access not enabled for group"
) )
@@ -620,7 +683,7 @@ describe("GroupsService", () => {
it("Should not remove a member to an existing group if API is disabled", async () => { it("Should not remove a member to an existing group if API is disabled", async () => {
const fun = groupsService.removeMembersWithAPIKey( const fun = groupsService.removeMembersWithAPIKey(
groupId, group.id,
["100001"], ["100001"],
apiKey apiKey
) )

View File

@@ -12,8 +12,8 @@ import {
import { InjectRepository } from "@nestjs/typeorm" import { InjectRepository } from "@nestjs/typeorm"
import { Group as CachedGroup } from "@semaphore-protocol/group" import { Group as CachedGroup } from "@semaphore-protocol/group"
import { Repository } from "typeorm" import { Repository } from "typeorm"
import { v4 } from "uuid"
import { InvitesService } from "../invites/invites.service" import { InvitesService } from "../invites/invites.service"
import { AdminsService } from "../admins/admins.service"
import { CreateGroupDto } from "./dto/create-group.dto" import { CreateGroupDto } from "./dto/create-group.dto"
import { UpdateGroupDto } from "./dto/update-group.dto" import { UpdateGroupDto } from "./dto/update-group.dto"
import { Group } from "./entities/group.entity" import { Group } from "./entities/group.entity"
@@ -31,7 +31,8 @@ export class GroupsService {
@InjectRepository(Member) @InjectRepository(Member)
private readonly memberRepository: Repository<Member>, private readonly memberRepository: Repository<Member>,
@Inject(forwardRef(() => InvitesService)) @Inject(forwardRef(() => InvitesService))
private readonly invitesService: InvitesService private readonly invitesService: InvitesService,
private readonly adminsService: AdminsService
) { ) {
this.cachedGroups = new Map() this.cachedGroups = new Map()
// this.bandadaContract = getBandadaContract( // this.bandadaContract = getBandadaContract(
@@ -138,7 +139,6 @@ export class GroupsService {
{ {
description, description,
treeDepth, treeDepth,
apiEnabled,
credentials, credentials,
fingerprintDuration fingerprintDuration
}: UpdateGroupDto, }: UpdateGroupDto,
@@ -176,15 +176,6 @@ export class GroupsService {
group.credentials = credentials group.credentials = credentials
} }
if (!group.credentials && apiEnabled !== undefined) {
group.apiEnabled = apiEnabled
// Generate a new API key if it doesn't exist
if (!group.apiKey) {
group.apiKey = v4()
}
}
await this.groupRepository.save(group) await this.groupRepository.save(group)
Logger.log(`GroupsService: group '${group.name}' has been updated`) Logger.log(`GroupsService: group '${group.name}' has been updated`)
@@ -192,37 +183,6 @@ export class GroupsService {
return group return group
} }
/**
* Updates the group api key.
* @param groupId Group id.
* @param adminId Group admin id.
*/
async updateApiKey(groupId: string, adminId: string): Promise<string> {
const group = await this.getGroup(groupId)
if (group.adminId !== adminId) {
throw new UnauthorizedException(
`You are not the admin of the group '${groupId}'`
)
}
if (!group.apiEnabled) {
throw new UnauthorizedException(
`Group '${groupId}' API key is not enabled`
)
}
group.apiKey = v4()
await this.groupRepository.save(group)
Logger.log(
`GroupsService: group '${group.name}' APIs have been updated`
)
return group.apiKey
}
/** /**
* Join the group by redeeming invite code. * Join the group by redeeming invite code.
* @param groupId Group id. * @param groupId Group id.
@@ -320,8 +280,15 @@ export class GroupsService {
apiKey: string apiKey: string
): Promise<Group> { ): Promise<Group> {
const group = await this.getGroup(groupId) const group = await this.getGroup(groupId)
const admin = await this.adminsService.findOne({ id: group.adminId })
if (!group.apiEnabled || group.apiKey !== apiKey) { if (!admin) {
throw new BadRequestException(
`Invalid admin for group '${groupId}'`
)
}
if (!admin.apiEnabled || admin.apiKey !== apiKey) {
throw new BadRequestException( throw new BadRequestException(
`Invalid API key or API access not enabled for group '${groupId}'` `Invalid API key or API access not enabled for group '${groupId}'`
) )
@@ -540,8 +507,15 @@ export class GroupsService {
apiKey: string apiKey: string
): Promise<Group> { ): Promise<Group> {
const group = await this.getGroup(groupId) const group = await this.getGroup(groupId)
const admin = await this.adminsService.findOne({ id: group.adminId })
if (!group.apiEnabled || group.apiKey !== apiKey) { if (!admin) {
throw new BadRequestException(
`Invalid admin for group '${groupId}'`
)
}
if (!admin.apiEnabled || admin.apiKey !== apiKey) {
throw new BadRequestException( throw new BadRequestException(
`Invalid API key or API access not enabled for group '${groupId}'` `Invalid API key or API access not enabled for group '${groupId}'`
) )

View File

@@ -23,17 +23,6 @@ describe("Groups utils", () => {
expect(members).toHaveLength(0) expect(members).toHaveLength(0)
}) })
it("Should map the group data with api keys if specified", async () => {
const { apiKey, apiEnabled } = mapGroupToResponseDTO(
{ apiEnabled: true, apiKey: "123" } as any,
"12345",
true
)
expect(apiEnabled).toBeTruthy()
expect(apiKey).toBe("123")
})
it("Should map the fingerprint correctly", async () => { it("Should map the fingerprint correctly", async () => {
const { fingerprint } = mapGroupToResponseDTO({} as any, "12345") const { fingerprint } = mapGroupToResponseDTO({} as any, "12345")

View File

@@ -1,10 +1,6 @@
import { Group } from "./entities/group.entity" import { Group } from "./entities/group.entity"
export function mapGroupToResponseDTO( export function mapGroupToResponseDTO(group: Group, fingerprint: string = "") {
group: Group,
fingerprint: string = "",
includeAPIKey: boolean = false
) {
const dto = { const dto = {
id: group.id, id: group.id,
name: group.name, name: group.name,
@@ -15,14 +11,7 @@ export function mapGroupToResponseDTO(
fingerprintDuration: group.fingerprintDuration, fingerprintDuration: group.fingerprintDuration,
createdAt: group.createdAt, createdAt: group.createdAt,
members: (group.members || []).map((m) => m.id), members: (group.members || []).map((m) => m.id),
credentials: group.credentials, credentials: group.credentials
apiKey: undefined,
apiEnabled: undefined
}
if (includeAPIKey) {
dto.apiKey = group.apiKey
dto.apiEnabled = group.apiEnabled
} }
return dto return dto

View File

@@ -4,11 +4,13 @@ import { GroupsModule } from "../groups/groups.module"
import { Invite } from "./entities/invite.entity" import { Invite } from "./entities/invite.entity"
import { InvitesController } from "./invites.controller" import { InvitesController } from "./invites.controller"
import { InvitesService } from "./invites.service" import { InvitesService } from "./invites.service"
import { AdminsModule } from "../admins/admins.module"
@Module({ @Module({
imports: [ imports: [
forwardRef(() => GroupsModule), forwardRef(() => GroupsModule),
TypeOrmModule.forFeature([Invite]) TypeOrmModule.forFeature([Invite]),
AdminsModule
], ],
controllers: [InvitesController], controllers: [InvitesController],
providers: [InvitesService], providers: [InvitesService],

View File

@@ -7,6 +7,7 @@ import { GroupsService } from "../groups/groups.service"
import { OAuthAccount } from "../credentials/entities/credentials-account.entity" import { OAuthAccount } from "../credentials/entities/credentials-account.entity"
import { Invite } from "./entities/invite.entity" import { Invite } from "./entities/invite.entity"
import { InvitesService } from "./invites.service" import { InvitesService } from "./invites.service"
import { AdminsModule } from "../admins/admins.module"
jest.mock("@bandada/utils", () => ({ jest.mock("@bandada/utils", () => ({
__esModule: true, __esModule: true,
@@ -37,7 +38,8 @@ describe("InvitesService", () => {
}) })
}), }),
TypeOrmModule.forFeature([Group, Invite, Member]), TypeOrmModule.forFeature([Group, Invite, Member]),
ScheduleModule.forRoot() ScheduleModule.forRoot(),
AdminsModule
], ],
providers: [GroupsService, InvitesService] providers: [GroupsService, InvitesService]
}).compile() }).compile()

View File

@@ -0,0 +1,10 @@
/**
* Defines the possible actions that can be performed on an API key.
* This includes generating a new API key, enabling an existing API key,
* and disabling an existing API key.
*/
export enum ApiKeyActions {
Generate = "generate",
Enable = "enable",
Disable = "disable"
}

View File

@@ -4,7 +4,10 @@ CREATE TABLE admins (
id character varying PRIMARY KEY, id character varying PRIMARY KEY,
address character varying NOT NULL UNIQUE, address character varying NOT NULL UNIQUE,
username character varying NOT NULL UNIQUE, username character varying NOT NULL UNIQUE,
created_at timestamp without time zone NOT NULL DEFAULT now() api_key character varying,
api_enabled boolean NOT NULL DEFAULT false,
created_at timestamp without time zone NOT NULL DEFAULT now(),
updated_at timestamp without time zone NOT NULL DEFAULT now()
); );
-- Table Definition ---------------------------------------------- -- Table Definition ----------------------------------------------
@@ -17,8 +20,6 @@ CREATE TABLE groups (
tree_depth integer NOT NULL, tree_depth integer NOT NULL,
fingerprint_duration integer NOT NULL, fingerprint_duration integer NOT NULL,
credentials text, credentials text,
api_enabled boolean NOT NULL DEFAULT false,
api_key character varying,
created_at timestamp without time zone NOT NULL DEFAULT now(), created_at timestamp without time zone NOT NULL DEFAULT now(),
updated_at timestamp without time zone NOT NULL DEFAULT now() updated_at timestamp without time zone NOT NULL DEFAULT now()
); );