From 339da483370f7841b4c1613541fdb7f119216434 Mon Sep 17 00:00:00 2001 From: Yaroslav Grishajev Date: Wed, 31 Jul 2024 17:42:49 +0200 Subject: [PATCH] feat(auth): implements basic anonymous user auth refs #247 --- apps/api/package.json | 2 + apps/api/src/app.ts | 8 +- apps/api/src/auth/index.ts | 0 .../auth/services/ability/ability.service.ts | 37 ++++++ .../api/src/auth/services/auth.interceptor.ts | 48 ++++++++ apps/api/src/auth/services/auth.service.ts | 53 +++++++++ .../controllers/wallet/wallet.controller.ts | 18 +-- .../user-wallet/user-wallet.repository.ts | 65 ++++++----- .../services/tx-signer/tx-signer.service.ts | 8 +- .../wallet-initializer.service.ts | 6 +- .../src/core/providers/request.provider.ts | 13 --- .../src/core/repositories/base.repository.ts | 35 ++++++ .../execution-context.service.ts | 34 ++++++ .../request-context.interceptor.ts | 24 ++++ .../request-storage.interceptor.ts | 35 ------ .../src/core/types/hono-interceptor.type.ts | 2 +- .../lib/drizzle-ability/drizzle-ability.ts | 108 ++++++++++++++++++ .../user/controllers/user/user.controller.ts | 13 ++- .../user/providers/current-user.provider.ts | 16 --- .../user/repositories/user/user.repository.ts | 30 +++-- .../create-anonymous-user.router.ts | 2 +- .../get-anonymous-user.router.ts | 2 +- .../user/{routes => }/schemas/user.schema.ts | 0 .../test/functional/anonymous-user.spec.ts | 29 ++++- .../api/test/functional/create-wallet.spec.ts | 42 ++++++- .../functional/sign-and-broadcast-tx.spec.ts | 90 +++++++++------ apps/api/test/services/wallet.service.ts | 4 +- .../src/services/http/http.service.ts | 24 +++- package-lock.json | 44 +++++++ 29 files changed, 624 insertions(+), 168 deletions(-) create mode 100644 apps/api/src/auth/index.ts create mode 100644 apps/api/src/auth/services/ability/ability.service.ts create mode 100644 apps/api/src/auth/services/auth.interceptor.ts create mode 100644 apps/api/src/auth/services/auth.service.ts delete mode 100644 apps/api/src/core/providers/request.provider.ts create mode 100644 apps/api/src/core/repositories/base.repository.ts create mode 100644 apps/api/src/core/services/execution-context/execution-context.service.ts create mode 100644 apps/api/src/core/services/request-storage/request-context.interceptor.ts delete mode 100644 apps/api/src/core/services/request-storage/request-storage.interceptor.ts create mode 100644 apps/api/src/lib/drizzle-ability/drizzle-ability.ts delete mode 100644 apps/api/src/user/providers/current-user.provider.ts rename apps/api/src/user/{routes => }/schemas/user.schema.ts (100%) diff --git a/apps/api/package.json b/apps/api/package.json index d1c4aebd8..906e2d038 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,6 +33,7 @@ "@akashnetwork/akashjs": "^0.10.0", "@akashnetwork/database": "*", "@akashnetwork/http-sdk": "*", + "@casl/ability": "^6.7.1", "@chain-registry/assets": "^0.7.1", "@cosmjs/amino": "^0.32.4", "@cosmjs/crypto": "^0.32.4", @@ -49,6 +50,7 @@ "@opentelemetry/instrumentation-pino": "^0.41.0", "@opentelemetry/sdk-node": "^0.52.1", "@sentry/node": "^7.55.2", + "@ucast/core": "^1.10.2", "async-sema": "^3.1.1", "axios": "^1.7.2", "commander": "^12.1.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c16e8f13a..efb6bf3fc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,11 +10,11 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { container } from "tsyringe"; +import { AuthInterceptor } from "@src/auth/services/auth.interceptor"; import { HonoErrorHandlerService } from "@src/core/services/hono-error-handler/hono-error-handler.service"; import { HttpLoggerService } from "@src/core/services/http-logger/http-logger.service"; import { LoggerService } from "@src/core/services/logger/logger.service"; -import { RequestStorageInterceptor } from "@src/core/services/request-storage/request-storage.interceptor"; -import { CurrentUserInterceptor } from "@src/user/services/current-user/current-user.interceptor"; +import { RequestContextInterceptor } from "@src/core/services/request-storage/request-context.interceptor"; import packageJson from "../package.json"; import { chainDb, syncUserSchema, userDb } from "./db/dbConnection"; import { apiRouter } from "./routers/apiRouter"; @@ -64,8 +64,8 @@ const scheduler = new Scheduler({ }); appHono.use(container.resolve(HttpLoggerService).intercept()); -appHono.use(container.resolve(RequestStorageInterceptor).intercept()); -appHono.use(container.resolve(CurrentUserInterceptor).intercept()); +appHono.use(container.resolve(RequestContextInterceptor).intercept()); +appHono.use(container.resolve(AuthInterceptor).intercept()); appHono.use( "*", sentry({ diff --git a/apps/api/src/auth/index.ts b/apps/api/src/auth/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/src/auth/services/ability/ability.service.ts b/apps/api/src/auth/services/ability/ability.service.ts new file mode 100644 index 000000000..41e38bb3e --- /dev/null +++ b/apps/api/src/auth/services/ability/ability.service.ts @@ -0,0 +1,37 @@ +import { Ability } from "@casl/ability"; +import template from "lodash/template"; +import { singleton } from "tsyringe"; + +@singleton() +export class AbilityService { + private readonly createRegularUserRules = template( + JSON.stringify([ + { action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } }, + { action: "read", subject: "User", conditions: { id: "${user.id}" } } + ]) + ); + private readonly createRegularAnonymousUserRules = template( + JSON.stringify([ + { action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } }, + { action: "read", subject: "User", conditions: { id: "${user.id}", userId: null } } + ]) + ); + + getAbilityForUser(user: { userId: string }) { + const rules = this.createRegularUserRules({ user }); + return new Ability(JSON.parse(rules)); + } + + getAbilityForAnonymousUser(user: { id: string }) { + const rules = this.createRegularAnonymousUserRules({ user }); + return new Ability(JSON.parse(rules)); + } + + getEmptyAbility() { + return new Ability([]); + } + + getSuperUserAbility() { + return new Ability([{ action: "manage", subject: "all" }]); + } +} diff --git a/apps/api/src/auth/services/auth.interceptor.ts b/apps/api/src/auth/services/auth.interceptor.ts new file mode 100644 index 000000000..4152c5fa5 --- /dev/null +++ b/apps/api/src/auth/services/auth.interceptor.ts @@ -0,0 +1,48 @@ +import { Context, Next } from "hono"; +import { singleton } from "tsyringe"; + +import { AbilityService } from "@src/auth/services/ability/ability.service"; +import { AuthService } from "@src/auth/services/auth.service"; +import { ExecutionContextService } from "@src/core/services/execution-context/execution-context.service"; +import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type"; +import { getCurrentUserId } from "@src/middlewares/userMiddleware"; +import { UserRepository } from "@src/user/repositories"; + +@singleton() +export class AuthInterceptor implements HonoInterceptor { + constructor( + private readonly abilityService: AbilityService, + private readonly userRepository: UserRepository, + private readonly executionContextService: ExecutionContextService, + private readonly authService: AuthService + ) {} + + intercept() { + return async (c: Context, next: Next) => { + const userId = getCurrentUserId(c); + + if (userId) { + const currentUser = await this.userRepository.findByUserId(userId); + + this.authService.currentUser = currentUser; + this.authService.ability = currentUser ? this.abilityService.getAbilityForUser(currentUser) : this.abilityService.getEmptyAbility(); + + return await next(); + } + const anonymousUserId = c.req.header("x-anonymous-user-id"); + + if (anonymousUserId) { + const currentUser = await this.userRepository.findAnonymousById(anonymousUserId); + + this.authService.currentUser = currentUser; + this.authService.ability = currentUser ? this.abilityService.getAbilityForAnonymousUser(currentUser) : this.abilityService.getEmptyAbility(); + + return await next(); + } + + this.authService.ability = this.abilityService.getEmptyAbility(); + + return await next(); + }; + } +} diff --git a/apps/api/src/auth/services/auth.service.ts b/apps/api/src/auth/services/auth.service.ts new file mode 100644 index 000000000..a77efd66f --- /dev/null +++ b/apps/api/src/auth/services/auth.service.ts @@ -0,0 +1,53 @@ +import { Ability } from "@casl/ability"; +import assert from "http-assert"; +import { container, Lifecycle, scoped } from "tsyringe"; + +import { ExecutionContextService } from "@src/core/services/execution-context/execution-context.service"; +import { UserOutput } from "@src/user/repositories"; + +@scoped(Lifecycle.ResolutionScoped) +export class AuthService { + constructor(private readonly executionContextService: ExecutionContextService) {} + + set currentUser(user: UserOutput) { + this.executionContextService.set("CURRENT_USER", user); + } + + get currentUser(): UserOutput { + return this.executionContextService.get("CURRENT_USER"); + } + + set ability(ability: Ability) { + this.executionContextService.set("ABILITY", ability); + } + + get ability(): Ability { + return this.executionContextService.get("ABILITY"); + } + + get isAuthenticated(): boolean { + return !!this.currentUser; + } + + throwUnlessCan(action: string, subject: string) { + assert(this.ability.can(action, subject), 403); + } +} + +export const Protected = (rules?: { action: string; subject: string }[]) => (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + + descriptor.value = function protectedFunction(...args: any[]) { + const authService = container.resolve(AuthService); + + assert(authService.isAuthenticated, 401); + + if (rules) { + rules.forEach(rule => authService.throwUnlessCan(rule.action, rule.subject)); + } + + return originalMethod.apply(this, args); + }; + + return descriptor; +}; diff --git a/apps/api/src/billing/controllers/wallet/wallet.controller.ts b/apps/api/src/billing/controllers/wallet/wallet.controller.ts index 3e4e469e5..f60f542e1 100644 --- a/apps/api/src/billing/controllers/wallet/wallet.controller.ts +++ b/apps/api/src/billing/controllers/wallet/wallet.controller.ts @@ -1,39 +1,43 @@ import type { EncodeObject } from "@cosmjs/proto-signing"; import pick from "lodash/pick"; -import { singleton } from "tsyringe"; +import { container, Lifecycle, scoped } from "tsyringe"; +import { AuthService, Protected } from "@src/auth/services/auth.service"; import type { WalletListOutputResponse, WalletOutputResponse } from "@src/billing/http-schemas/wallet.schema"; import { UserWalletRepository } from "@src/billing/repositories"; import type { CreateWalletRequestInput, SignTxRequestInput, SignTxResponseOutput } from "@src/billing/routes"; import { GetWalletQuery } from "@src/billing/routes/get-wallet-list/get-wallet-list.router"; -import { ManagedUserWalletService, WalletInitializerService } from "@src/billing/services"; +import { WalletInitializerService } from "@src/billing/services"; import { RefillService } from "@src/billing/services/refill/refill.service"; import { TxSignerService } from "@src/billing/services/tx-signer/tx-signer.service"; -// TODO: authorize endpoints below -@singleton() +@scoped(Lifecycle.ResolutionScoped) export class WalletController { constructor( - private readonly walletManager: ManagedUserWalletService, private readonly userWalletRepository: UserWalletRepository, private readonly walletInitializer: WalletInitializerService, private readonly signerService: TxSignerService, - private readonly refillService: RefillService + private readonly refillService: RefillService, + private readonly authService: AuthService ) {} + @Protected([{ action: "create", subject: "UserWallet" }]) async create({ data: { userId } }: CreateWalletRequestInput): Promise { return { data: await this.walletInitializer.initialize(userId) }; } + @Protected([{ action: "read", subject: "UserWallet" }]) async getWallets(query: GetWalletQuery): Promise { - const wallets = await this.userWalletRepository.find(query); + const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query); + return { data: wallets.map(wallet => pick(wallet, ["id", "userId", "address", "creditAmount"])) }; } + @Protected([{ action: "sign", subject: "UserWallet" }]) async signTx({ data: { userId, messages } }: SignTxRequestInput): Promise { return { data: await this.signerService.signAndBroadcast(userId, messages as EncodeObject[]) diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index 0c0060f13..5b4827751 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -4,6 +4,7 @@ import { singleton } from "tsyringe"; import { InjectUserWalletSchema, UserWalletSchema } from "@src/billing/providers"; import { ApiPgDatabase, InjectPg } from "@src/core/providers"; +import { AbilityParams, BaseRepository } from "@src/core/repositories/base.repository"; import { TxService } from "@src/core/services"; export type UserWalletInput = Partial; @@ -18,35 +19,37 @@ export interface ListOptions { } @singleton() -export class UserWalletRepository { - get cursor() { - return this.txManager.getPgTx() || this.pg; +export class UserWalletRepository extends BaseRepository { + constructor( + @InjectPg() protected readonly pg: ApiPgDatabase, + @InjectUserWalletSchema() protected readonly schema: UserWalletSchema, + protected readonly txManager: TxService + ) { + super(pg, schema, txManager, "UserWallet"); } - constructor( - @InjectPg() private readonly pg: ApiPgDatabase, - @InjectUserWalletSchema() private readonly userWallet: UserWalletSchema, - private readonly txManager: TxService - ) {} + accessibleBy(...abilityParams: AbilityParams) { + return new UserWalletRepository(this.pg, this.schema, this.txManager).withAbility(...abilityParams) as this; + } async create(input: Pick) { - return this.toOutput( - first( - await this.cursor - .insert(this.userWallet) - .values({ - userId: input.userId, - address: input.address - }) - .returning() - ) - ); + const value = { + userId: input.userId, + address: input.address + }; + + this.ability?.throwUnlessCanExecute(value); + + return this.toOutput(first(await this.cursor.insert(this.schema).values(value).returning())); } async updateById(id: UserWalletOutput["id"], payload: Partial, options?: { returning: true }): Promise; async updateById(id: UserWalletOutput["id"], payload: Partial): Promise; async updateById(id: UserWalletOutput["id"], payload: Partial, options?: { returning: boolean }): Promise { - const cursor = this.cursor.update(this.userWallet).set(payload).where(eq(this.userWallet.id, id)); + const cursor = this.cursor + .update(this.schema) + .set(payload) + .where(this.whereAccessibleBy(eq(this.schema.id, id))); if (options?.returning) { const items = await cursor.returning(); @@ -60,36 +63,40 @@ export class UserWalletRepository { async find(query?: Partial) { const fields = query && (Object.keys(query) as Array); - const where = fields?.length ? and(...fields.map(field => eq(this.userWallet[field], query[field]))) : undefined; + const where = fields?.length ? and(...fields.map(field => eq(this.schema[field], query[field]))) : undefined; return this.toOutputList( await this.cursor.query.userWalletSchema.findMany({ - where + where: this.whereAccessibleBy(where) }) ); } async findDrainingWallets(thresholds = { fee: 0, deployment: 0 }, options?: Pick) { + const where = or(lte(this.schema.deploymentAllowance, thresholds.deployment.toString()), lte(this.schema.feeAllowance, thresholds.fee.toString())); + return this.toOutputList( await this.cursor.query.userWalletSchema.findMany({ - where: or(lte(this.userWallet.deploymentAllowance, thresholds.deployment.toString()), lte(this.userWallet.feeAllowance, thresholds.fee.toString())), + where: this.whereAccessibleBy(where), limit: options?.limit || 10 }) ); } async findByUserId(userId: UserWalletOutput["userId"]) { - return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: eq(this.userWallet.userId, userId) })); + return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.userId, userId)) })); } private toOutputList(dbOutput: UserWalletSchema["$inferSelect"][]): UserWalletOutput[] { return dbOutput.map(item => this.toOutput(item)); } - private toOutput(dbOutput: UserWalletSchema["$inferSelect"]): UserWalletOutput { - return { - ...dbOutput, - creditAmount: parseFloat(dbOutput.deploymentAllowance) + parseFloat(dbOutput.feeAllowance) - }; + private toOutput(dbOutput?: UserWalletSchema["$inferSelect"]): UserWalletOutput { + return ( + dbOutput && { + ...dbOutput, + creditAmount: parseFloat(dbOutput.deploymentAllowance) + parseFloat(dbOutput.feeAllowance) + } + ); } } diff --git a/apps/api/src/billing/services/tx-signer/tx-signer.service.ts b/apps/api/src/billing/services/tx-signer/tx-signer.service.ts index 637d23cdd..6deeecd91 100644 --- a/apps/api/src/billing/services/tx-signer/tx-signer.service.ts +++ b/apps/api/src/billing/services/tx-signer/tx-signer.service.ts @@ -6,6 +6,7 @@ import assert from "http-assert"; import pick from "lodash/pick"; import { singleton } from "tsyringe"; +import { AuthService } from "@src/auth/services/auth.service"; import { BillingConfig, InjectBillingConfig } from "@src/billing/providers"; import { InjectTypeRegistry } from "@src/billing/providers/type-registry.provider"; import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; @@ -28,12 +29,13 @@ export class TxSignerService { @InjectTypeRegistry() private readonly registry: Registry, private readonly userWalletRepository: UserWalletRepository, private readonly masterWalletService: MasterWalletService, - private readonly balancesService: BalancesService + private readonly balancesService: BalancesService, + private readonly authService: AuthService ) {} async signAndBroadcast(userId: UserWalletOutput["userId"], messages: StringifiedEncodeObject[]) { - const userWallet = await this.userWalletRepository.findByUserId(userId); - assert(userWallet, 403, "User wallet not found"); + const userWallet = await this.userWalletRepository.accessibleBy(this.authService.ability, "sign").findByUserId(userId); + assert(userWallet, 403); const decodedMessages = this.decodeMessages(messages); diff --git a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts index 305f80a13..7e96a658d 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts @@ -1,6 +1,7 @@ import pick from "lodash/pick"; import { singleton } from "tsyringe"; +import { AuthService } from "@src/auth/services/auth.service"; import { UserWalletInput, UserWalletRepository } from "@src/billing/repositories"; import { ManagedUserWalletService } from "@src/billing/services"; import { WithTransaction } from "@src/core/services"; @@ -9,12 +10,13 @@ import { WithTransaction } from "@src/core/services"; export class WalletInitializerService { constructor( private readonly walletManager: ManagedUserWalletService, - private readonly userWalletRepository: UserWalletRepository + private readonly userWalletRepository: UserWalletRepository, + private readonly authService: AuthService ) {} @WithTransaction() async initialize(userId: UserWalletInput["userId"]) { - const { id } = await this.userWalletRepository.create({ userId }); + const { id } = await this.userWalletRepository.accessibleBy(this.authService.ability, "create").create({ userId }); const wallet = await this.walletManager.createAndAuthorizeTrialSpending({ addressIndex: id }); const userWallet = await this.userWalletRepository.updateById( id, diff --git a/apps/api/src/core/providers/request.provider.ts b/apps/api/src/core/providers/request.provider.ts deleted file mode 100644 index 64e3a8b68..000000000 --- a/apps/api/src/core/providers/request.provider.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { container, inject } from "tsyringe"; - -import { RequestStorageInterceptor } from "@src/core/services/request-storage/request-storage.interceptor"; - -const REQUEST = "REQUEST"; - -container.register(REQUEST, { - useFactory: c => { - return c.resolve(RequestStorageInterceptor).context; - } -}); - -export const Request = () => inject(REQUEST); diff --git a/apps/api/src/core/repositories/base.repository.ts b/apps/api/src/core/repositories/base.repository.ts new file mode 100644 index 000000000..a02bc6be4 --- /dev/null +++ b/apps/api/src/core/repositories/base.repository.ts @@ -0,0 +1,35 @@ +import { AnyAbility } from "@casl/ability"; +import { PgTableWithColumns } from "drizzle-orm/pg-core/table"; +import { SQL } from "drizzle-orm/sql/sql"; + +import { ApiPgDatabase, InjectPg, TxService } from "@src/core"; +import { DrizzleAbility } from "@src/lib/drizzle-ability/drizzle-ability"; +import { InjectUserSchema } from "@src/user/providers"; + +export type AbilityParams = [AnyAbility, Parameters[0]]; + +export abstract class BaseRepository> { + protected ability?: DrizzleAbility; + + get cursor() { + return this.txManager.getPgTx() || this.pg; + } + + constructor( + @InjectPg() protected readonly pg: ApiPgDatabase, + @InjectUserSchema() protected readonly schema: T, + protected readonly txManager: TxService, + protected readonly entityName: string + ) {} + + protected withAbility(ability: AnyAbility, action: Parameters[0]) { + this.ability = new DrizzleAbility(this.schema, ability, action, this.entityName); + return this; + } + + protected whereAccessibleBy(where: SQL) { + return this.ability?.whereAccessibleBy(where) || where; + } + + abstract accessibleBy(...abilityParams: AbilityParams): this; +} diff --git a/apps/api/src/core/services/execution-context/execution-context.service.ts b/apps/api/src/core/services/execution-context/execution-context.service.ts new file mode 100644 index 000000000..48ab0d9a8 --- /dev/null +++ b/apps/api/src/core/services/execution-context/execution-context.service.ts @@ -0,0 +1,34 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { singleton } from "tsyringe"; + +@singleton() +export class ExecutionContextService { + private readonly storage = new AsyncLocalStorage>(); + + private get context() { + const store = this.storage.getStore(); + + if (!store) { + throw new Error("No context available"); + } + + return store; + } + + set(key: string, value: any) { + this.context.set(key, value); + } + + get(key: string) { + return this.context.get(key); + } + + async runWithContext(cb: (...args: any[]) => Promise): Promise { + return await new Promise((resolve, reject) => { + this.storage.run(new Map(), () => { + this.storage.getStore(); + cb().then(resolve).catch(reject); + }); + }); + } +} diff --git a/apps/api/src/core/services/request-storage/request-context.interceptor.ts b/apps/api/src/core/services/request-storage/request-context.interceptor.ts new file mode 100644 index 000000000..0c2830ead --- /dev/null +++ b/apps/api/src/core/services/request-storage/request-context.interceptor.ts @@ -0,0 +1,24 @@ +import { Context, Next } from "hono"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { singleton } from "tsyringe"; + +import { ExecutionContextService } from "@src/core/services/execution-context/execution-context.service"; +import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type"; + +@singleton() +export class RequestContextInterceptor implements HonoInterceptor { + private readonly HTTP_CONTEXT_KEY = "HTTP_CONTEXT"; + + private readonly storage = new AsyncLocalStorage>(); + + constructor(private readonly executionContextService: ExecutionContextService) {} + + intercept() { + return async (c: Context, next: Next) => { + await this.executionContextService.runWithContext(async () => { + this.executionContextService.set(this.HTTP_CONTEXT_KEY, c); + await next(); + }); + }; + } +} diff --git a/apps/api/src/core/services/request-storage/request-storage.interceptor.ts b/apps/api/src/core/services/request-storage/request-storage.interceptor.ts deleted file mode 100644 index f812f97d4..000000000 --- a/apps/api/src/core/services/request-storage/request-storage.interceptor.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Context, Next } from "hono"; -import { AsyncLocalStorage } from "node:async_hooks"; -import { singleton } from "tsyringe"; -import { v4 as uuid } from "uuid"; - -import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type"; - -@singleton() -export class RequestStorageInterceptor implements HonoInterceptor { - private readonly CONTEXT_KEY = "CONTEXT"; - - private readonly storage = new AsyncLocalStorage>(); - - get context() { - return this.storage.getStore()?.get(this.CONTEXT_KEY); - } - - intercept() { - return async (c: Context, next: Next) => { - const requestId = c.req.header("X-Request-Id") || uuid(); - c.set("requestId", requestId); - - await this.runWithContext(c, next); - }; - } - - private async runWithContext(context: Context, cb: () => Promise) { - return await new Promise((resolve, reject) => { - this.storage.run(new Map(), () => { - this.storage.getStore().set(this.CONTEXT_KEY, context); - cb().then(resolve).catch(reject); - }); - }); - } -} diff --git a/apps/api/src/core/types/hono-interceptor.type.ts b/apps/api/src/core/types/hono-interceptor.type.ts index ae3a32e49..3f5310096 100644 --- a/apps/api/src/core/types/hono-interceptor.type.ts +++ b/apps/api/src/core/types/hono-interceptor.type.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono"; export interface HonoInterceptor { - intercept(): MiddlewareHandler; + intercept(options?: any): MiddlewareHandler; } diff --git a/apps/api/src/lib/drizzle-ability/drizzle-ability.ts b/apps/api/src/lib/drizzle-ability/drizzle-ability.ts new file mode 100644 index 000000000..28a511493 --- /dev/null +++ b/apps/api/src/lib/drizzle-ability/drizzle-ability.ts @@ -0,0 +1,108 @@ +import { Abilities, AnyAbility, CanParameters, ForbiddenError, subject } from "@casl/ability"; +import { rulesToQuery } from "@casl/ability/extra"; +import { CompoundCondition, FieldCondition } from "@ucast/core"; +import { and, BinaryOperator, eq, gt, gte, isNull, lt, lte, ne, or } from "drizzle-orm"; +import { PgTableWithColumns } from "drizzle-orm/pg-core/table"; +import { SQL } from "drizzle-orm/sql/sql"; + +export class DrizzleAbility, A extends AnyAbility = AnyAbility> { + private readonly OPS: Record = { + eq: eq, + ne: ne, + gt: gt, + gte: gte, + lt: lt, + lte: lte + }; + + private readonly OPS_INVERTED: Record = { + eq: "ne", + ne: "eq", + gt: "lte", + gte: "lt", + lt: "gte", + lte: "gt", + in: "nin", + nin: "in" + }; + private readonly abilityClause = this.toDrizzleWhereClause(); + + constructor( + private readonly table: T, + private readonly ability: A, + private readonly action: CanParameters[0], + private readonly subjectType: CanParameters[1] + ) {} + + throwUnlessCanExecute(payload: Record) { + const params = [this.action, subject(this.subjectType as string, payload)] as unknown as Parameters; + ForbiddenError.from(this.ability).throwUnlessCan(...params); + } + + whereAccessibleBy(where: SQL) { + return this.abilityClause ? and(where, this.abilityClause) : where; + } + + private toDrizzleWhereClause() { + const params = [this.action, this.subjectType] as unknown as Parameters; + ForbiddenError.from(this.ability).throwUnlessCan(...params); + + const { $and = [], $or = [] } = rulesToQuery(this.ability, params[0], params[1], rule => { + if (!rule.ast) { + throw new Error("Unable to create knex query without AST"); + } + + if (rule.inverted) { + return { + ...rule.ast, + operator: this.OPS_INVERTED[rule.ast.operator] + }; + } + + return rule.ast; + }) as { $and: FieldCondition[]; $or: FieldCondition[] }; + + if (!$and.length && !$or.length) { + return; + } + + const conditions: (FieldCondition | CompoundCondition)[] = $and; + + if ($or.length) { + conditions.push(new CompoundCondition("or", $or)); + } + + return this.buildCondition(new CompoundCondition("and", conditions)); + } + + private buildCondition(condition: CompoundCondition> | FieldCondition): SQL | undefined { + if (!condition.operator || !("value" in condition)) { + throw new Error("Invalid condition structure"); + } + + if (condition instanceof CompoundCondition) { + switch (condition.operator.toLowerCase()) { + case "and": + return condition.value.length === 1 ? this.buildCondition(condition.value[0]) : and(...condition.value.map(value => this.buildCondition(value))); + case "or": + return condition.value.length === 1 ? this.buildCondition(condition.value[0]) : or(...condition.value.map(value => this.buildCondition(value))); + } + } + + if (condition instanceof FieldCondition) { + if (condition.value === null) { + return isNull(this.table[condition.field]); + } + + const op = this.OPS[condition.operator.toLowerCase()]; + + if (!op) { + throw new Error(`Unsupported operator: ${condition.operator}`); + } + + return op(this.table[condition.field], condition.value); + } + + throw new Error("Unsupported condition type"); + } +} diff --git a/apps/api/src/user/controllers/user/user.controller.ts b/apps/api/src/user/controllers/user/user.controller.ts index 657eebbe7..b8758da53 100644 --- a/apps/api/src/user/controllers/user/user.controller.ts +++ b/apps/api/src/user/controllers/user/user.controller.ts @@ -1,13 +1,17 @@ import assert from "http-assert"; import { singleton } from "tsyringe"; +import { AuthService, Protected } from "@src/auth/services/auth.service"; import { UserRepository } from "@src/user/repositories"; import { GetUserParams } from "@src/user/routes/get-anonymous-user/get-anonymous-user.router"; -import { AnonymousUserResponseOutput } from "@src/user/routes/schemas/user.schema"; +import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema"; @singleton() export class UserController { - constructor(private readonly userRepository: UserRepository) {} + constructor( + private readonly userRepository: UserRepository, + private readonly authService: AuthService + ) {} async create(): Promise { return { @@ -15,10 +19,11 @@ export class UserController { }; } + @Protected([{ action: "read", subject: "User" }]) async getById({ id }: GetUserParams): Promise { - const user = await this.userRepository.findAnonymousById(id); + const user = await this.userRepository.accessibleBy(this.authService.ability, "read").findById(id); - assert(user, 404, "User not found"); + assert(user, 404); return { data: user }; } diff --git a/apps/api/src/user/providers/current-user.provider.ts b/apps/api/src/user/providers/current-user.provider.ts deleted file mode 100644 index c593cdd31..000000000 --- a/apps/api/src/user/providers/current-user.provider.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { container, inject } from "tsyringe"; - -import { RequestStorageInterceptor } from "@src/core/services/request-storage/request-storage.interceptor"; -import { CURRENT_USER } from "@src/user/services/current-user/current-user.interceptor"; - -container.register(CURRENT_USER, { - useFactory: c => { - return c.resolve(RequestStorageInterceptor).context.get(CURRENT_USER); - } -}); - -export const CurrentUser = () => inject(CURRENT_USER); -export type CurrentUser = { - userId: string; - isAnonymous: boolean; -}; diff --git a/apps/api/src/user/repositories/user/user.repository.ts b/apps/api/src/user/repositories/user/user.repository.ts index a00e9a3bd..0ec67c1ae 100644 --- a/apps/api/src/user/repositories/user/user.repository.ts +++ b/apps/api/src/user/repositories/user/user.repository.ts @@ -3,25 +3,39 @@ import first from "lodash/first"; import { singleton } from "tsyringe"; import { ApiPgDatabase, InjectPg } from "@src/core/providers"; +import { AbilityParams, BaseRepository } from "@src/core/repositories/base.repository"; import { TxService } from "@src/core/services"; import { InjectUserSchema, UserSchema } from "@src/user/providers"; export type UserOutput = UserSchema["$inferSelect"]; @singleton() -export class UserRepository { +export class UserRepository extends BaseRepository { constructor( - @InjectPg() private readonly pg: ApiPgDatabase, - @InjectUserSchema() private readonly users: UserSchema, - private readonly txManager: TxService - ) {} + @InjectPg() protected readonly pg: ApiPgDatabase, + @InjectUserSchema() protected readonly schema: UserSchema, + protected readonly txManager: TxService + ) { + super(pg, schema, txManager, "User"); + } + + accessibleBy(...abilityParams: AbilityParams) { + return new UserRepository(this.pg, this.schema, this.txManager).withAbility(...abilityParams) as this; + } async create() { - const pg = this.txManager.getPgTx() || this.pg; - return first(await pg.insert(this.users).values({}).returning({ id: this.users.id })); + return first(await this.cursor.insert(this.schema).values({}).returning({ id: this.schema.id })); + } + + async findByUserId(userId: UserOutput["userId"]) { + return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.userId, userId)) }); + } + + async findById(id: UserOutput["id"]) { + return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.id, id)) }); } async findAnonymousById(id: UserOutput["id"]) { - return await this.pg.query.userSchema.findFirst({ where: and(eq(this.users.id, id), isNull(this.users.userId)), columns: { id: true } }); + return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(and(eq(this.schema.id, id), isNull(this.schema.userId))) }); } } diff --git a/apps/api/src/user/routes/create-anonymous-user/create-anonymous-user.router.ts b/apps/api/src/user/routes/create-anonymous-user/create-anonymous-user.router.ts index 98f86eb16..6e692b06a 100644 --- a/apps/api/src/user/routes/create-anonymous-user/create-anonymous-user.router.ts +++ b/apps/api/src/user/routes/create-anonymous-user/create-anonymous-user.router.ts @@ -2,7 +2,7 @@ import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import { container } from "tsyringe"; import { UserController } from "@src/user/controllers/user/user.controller"; -import { AnonymousUserResponseOutputSchema } from "@src/user/routes/schemas/user.schema"; +import { AnonymousUserResponseOutputSchema } from "@src/user/schemas/user.schema"; const route = createRoute({ method: "post", diff --git a/apps/api/src/user/routes/get-anonymous-user/get-anonymous-user.router.ts b/apps/api/src/user/routes/get-anonymous-user/get-anonymous-user.router.ts index 594d314eb..ddcf63ccc 100644 --- a/apps/api/src/user/routes/get-anonymous-user/get-anonymous-user.router.ts +++ b/apps/api/src/user/routes/get-anonymous-user/get-anonymous-user.router.ts @@ -3,7 +3,7 @@ import { container } from "tsyringe"; import { z } from "zod"; import { UserController } from "@src/user/controllers/user/user.controller"; -import { AnonymousUserResponseOutputSchema } from "@src/user/routes/schemas/user.schema"; +import { AnonymousUserResponseOutputSchema } from "@src/user/schemas/user.schema"; export const GetUserParamsSchema = z.object({ id: z.string() }); diff --git a/apps/api/src/user/routes/schemas/user.schema.ts b/apps/api/src/user/schemas/user.schema.ts similarity index 100% rename from apps/api/src/user/routes/schemas/user.schema.ts rename to apps/api/src/user/schemas/user.schema.ts diff --git a/apps/api/test/functional/anonymous-user.spec.ts b/apps/api/test/functional/anonymous-user.spec.ts index bf96cb623..d784f32ab 100644 --- a/apps/api/test/functional/anonymous-user.spec.ts +++ b/apps/api/test/functional/anonymous-user.spec.ts @@ -3,7 +3,7 @@ import { container } from "tsyringe"; import { app } from "@src/app"; import { ApiPgDatabase, POSTGRES_DB } from "@src/core"; import { USER_SCHEMA, UserSchema } from "@src/user/providers"; -import { AnonymousUserResponseOutput } from "@src/user/routes/schemas/user.schema"; +import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema"; describe("Users", () => { const schema = container.resolve(USER_SCHEMA); @@ -32,11 +32,36 @@ describe("Users", () => { it("should retrieve a user", async () => { const getUserResponse = await app.request(`/v1/anonymous-users/${user.id}`, { method: "GET", - headers: new Headers({ "Content-Type": "application/json" }) + headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id }) }); const retrievedUser = await getUserResponse.json(); expect(retrievedUser).toMatchObject({ data: user }); }); + + it("should throw 401 provided no auth header", async () => { + const res = await app.request(`/v1/anonymous-users/${user.id}`, { + method: "GET", + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ error: "UnauthorizedError", message: "Unauthorized" }); + }); + + it("should throw 404 provided a different user auth header", async () => { + const differentUserResponse = await app.request("/v1/anonymous-users", { + method: "POST", + headers: new Headers({ "Content-Type": "application/json" }) + }); + const { data: differentUser } = await differentUserResponse.json(); + const res = await app.request(`/v1/anonymous-users/${user.id}`, { + method: "GET", + headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": differentUser.id }) + }); + + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ error: "NotFoundError", message: "Not Found" }); + }); }); }); diff --git a/apps/api/test/functional/create-wallet.spec.ts b/apps/api/test/functional/create-wallet.spec.ts index 1856b47f8..3d000792b 100644 --- a/apps/api/test/functional/create-wallet.spec.ts +++ b/apps/api/test/functional/create-wallet.spec.ts @@ -1,3 +1,4 @@ +import { faker } from "@faker-js/faker"; import { eq } from "drizzle-orm"; import { container } from "tsyringe"; @@ -28,15 +29,18 @@ describe("wallets", () => { const { data: { id: userId } } = await userResponse.json(); - const res = await app.request("/v1/wallets", { + const headers = new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": userId }); + const createWalletResponse = await app.request("/v1/wallets", { method: "POST", body: JSON.stringify({ data: { userId } }), - headers: new Headers({ "Content-Type": "application/json" }) + headers }); + const getWalletsResponse = await app.request(`/v1/wallets?userId=${userId}`, { headers }); const userWallet = await userWalletsTable.findFirst({ where: eq(userWalletSchema.userId, userId) }); - expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ + expect(createWalletResponse.status).toBe(200); + expect(getWalletsResponse.status).toBe(200); + expect(await createWalletResponse.json()).toMatchObject({ data: { id: expect.any(Number), userId, @@ -44,6 +48,16 @@ describe("wallets", () => { address: expect.any(String) } }); + expect(await getWalletsResponse.json()).toMatchObject({ + data: [ + { + id: expect.any(Number), + userId, + creditAmount: expect.any(Number), + address: expect.any(String) + } + ] + }); expect(userWallet).toMatchObject({ id: expect.any(Number), userId, @@ -52,5 +66,25 @@ describe("wallets", () => { feeAllowance: `${config.TRIAL_FEES_ALLOWANCE_AMOUNT}.00` }); }); + + it("should throw 401 provided no auth header ", async () => { + const createWalletResponse = await app.request("/v1/wallets", { + method: "POST", + body: JSON.stringify({ data: { userId: faker.string.uuid() } }), + headers: new Headers({ "Content-Type": "application/json" }) + }); + + expect(createWalletResponse.status).toBe(401); + expect(await createWalletResponse.json()).toMatchObject({ error: "UnauthorizedError", message: "Unauthorized" }); + }); + }); + + describe("GET /v1/wallets", () => { + it("should throw 401 provided no auth header ", async () => { + const getWalletsResponse = await app.request(`/v1/wallets?userId=${faker.string.uuid()}`); + + expect(getWalletsResponse.status).toBe(401); + expect(await getWalletsResponse.json()).toMatchObject({ error: "UnauthorizedError", message: "Unauthorized" }); + }); }); }); diff --git a/apps/api/test/functional/sign-and-broadcast-tx.spec.ts b/apps/api/test/functional/sign-and-broadcast-tx.spec.ts index 45b94aeab..8c771c395 100644 --- a/apps/api/test/functional/sign-and-broadcast-tx.spec.ts +++ b/apps/api/test/functional/sign-and-broadcast-tx.spec.ts @@ -1,5 +1,6 @@ import { certificateManager } from "@akashnetwork/akashjs/build/certificates/certificate-manager"; import type { Registry } from "@cosmjs/proto-signing"; +import { WalletService } from "@test/services/wallet.service"; import { container } from "tsyringe"; import { app } from "@src/app"; @@ -8,13 +9,14 @@ import { TYPE_REGISTRY } from "@src/billing/providers/type-registry.provider"; import { ApiPgDatabase, POSTGRES_DB } from "@src/core"; import { USER_SCHEMA, UserSchema } from "@src/user/providers"; -jest.setTimeout(20000); +jest.setTimeout(30000); describe("Tx Sign", () => { const registry = container.resolve(TYPE_REGISTRY); const db = container.resolve(POSTGRES_DB); const userWalletSchema = container.resolve(USER_WALLET_SCHEMA); const userSchema = container.resolve(USER_SCHEMA); + const walletService = new WalletService(app); afterEach(async () => { await Promise.all([db.delete(userWalletSchema), db.delete(userSchema)]); @@ -22,49 +24,69 @@ describe("Tx Sign", () => { describe("POST /v1/tx", () => { it("should create a wallet for a user", async () => { - const userResponse = await app.request("/v1/anonymous-users", { + const { user, wallet } = await walletService.createUserAndWallet(); + const res = await app.request("/v1/tx", { method: "POST", - headers: new Headers({ "Content-Type": "application/json" }) + body: await createMessagePayload(user.id, wallet.address), + headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id }) }); - const { - data: { id: userId } - } = await userResponse.json(); - const walletResponse = await app.request("/v1/wallets", { + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ data: { code: 0, transactionHash: expect.any(String) } }); + }); + + it("should throw 401 provided no auth header", async () => { + const { user, wallet } = await walletService.createUserAndWallet(); + const res = await app.request("/v1/tx", { method: "POST", - body: JSON.stringify({ - data: { userId } - }), + body: await createMessagePayload(user.id, wallet.address), headers: new Headers({ "Content-Type": "application/json" }) }); - const { data: wallet } = await walletResponse.json(); - const { cert, publicKey } = certificateManager.generatePEM(wallet.address); - const message = { - typeUrl: "/akash.cert.v1beta3.MsgCreateCertificate", - value: { - owner: wallet.address, - cert: Buffer.from(cert).toString("base64"), - pubkey: Buffer.from(publicKey).toString("base64") - } - }; - const res = await app.request("/v1/tx", { + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ error: "UnauthorizedError", message: "Unauthorized" }); + }); + + it("should throw 403 provided a different user auth header", async () => { + const { user, wallet } = await walletService.createUserAndWallet(); + const differentUserResponse = await app.request("/v1/anonymous-users", { method: "POST", - body: JSON.stringify({ - data: { - userId: userId, - messages: [ - { - typeUrl: message.typeUrl, - value: Buffer.from(registry.encode(message)).toString("base64") - } - ] - } - }), headers: new Headers({ "Content-Type": "application/json" }) }); + const { data: differentUser } = await differentUserResponse.json(); + const res = await app.request("/v1/tx", { + method: "POST", + body: await createMessagePayload(user.id, wallet.address), + headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": differentUser.id }) + }); - expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ data: { code: 0, transactionHash: expect.any(String) } }); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ error: "ForbiddenError", message: "Forbidden" }); }); }); + + async function createMessagePayload(userId: string, address: string) { + const { cert, publicKey } = certificateManager.generatePEM(address); + + const message = { + typeUrl: "/akash.cert.v1beta3.MsgCreateCertificate", + value: { + owner: address, + cert: Buffer.from(cert).toString("base64"), + pubkey: Buffer.from(publicKey).toString("base64") + } + }; + + return JSON.stringify({ + data: { + userId: userId, + messages: [ + { + typeUrl: message.typeUrl, + value: Buffer.from(registry.encode(message)).toString("base64") + } + ] + } + }); + } }); diff --git a/apps/api/test/services/wallet.service.ts b/apps/api/test/services/wallet.service.ts index a2c07c65a..a01da5617 100644 --- a/apps/api/test/services/wallet.service.ts +++ b/apps/api/test/services/wallet.service.ts @@ -14,7 +14,7 @@ export class WalletService { body: JSON.stringify({ data: { userId: user.id } }), - headers: new Headers({ "Content-Type": "application/json" }) + headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": user.id }) }); const { data: wallet } = await walletResponse.json(); @@ -23,7 +23,7 @@ export class WalletService { async getWalletByUserId(userId: string): Promise<{ id: number; address: string; creditAmount: number }> { const walletResponse = await this.app.request(`/v1/wallets?userId=${userId}`, { - headers: new Headers({ "Content-Type": "application/json" }) + headers: new Headers({ "Content-Type": "application/json", "x-anonymous-user-id": userId }) }); const { data } = await walletResponse.json(); diff --git a/apps/deploy-web/src/services/http/http.service.ts b/apps/deploy-web/src/services/http/http.service.ts index 71ff102fa..cfd66f368 100644 --- a/apps/deploy-web/src/services/http/http.service.ts +++ b/apps/deploy-web/src/services/http/http.service.ts @@ -1,10 +1,30 @@ import { ManagedWalletHttpService, TxHttpService, UserHttpService } from "@akashnetwork/http-sdk"; +import { InternalAxiosRequestConfig } from "axios"; -import { BASE_API_URL } from "@src/utils/constants"; +import { ANONYMOUS_USER_KEY, BASE_API_URL } from "@src/utils/constants"; import { customRegistry } from "@src/utils/customRegistry"; const apiConfig = { baseURL: BASE_API_URL }; export const userHttpService = new UserHttpService(apiConfig); -export const txHttpService = new TxHttpService(customRegistry, apiConfig); export const managedWalletHttpService = new ManagedWalletHttpService(apiConfig); +export const txHttpService = new TxHttpService(customRegistry, apiConfig); + +let anonymousUserId: string; + +const withAnonymousUserHeader = (config: InternalAxiosRequestConfig) => { + if (!anonymousUserId) { + const user = localStorage.getItem(ANONYMOUS_USER_KEY); + anonymousUserId = user ? JSON.parse(user).id : undefined; + } + + if (anonymousUserId) { + config.headers.set("x-anonymous-user-id", anonymousUserId); + } + + return config; +}; + +userHttpService.interceptors.request.use(withAnonymousUserHeader); +managedWalletHttpService.interceptors.request.use(withAnonymousUserHeader); +txHttpService.interceptors.request.use(withAnonymousUserHeader); diff --git a/package-lock.json b/package-lock.json index 255ec2488..b4cf74035 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@akashnetwork/akashjs": "^0.10.0", "@akashnetwork/database": "*", "@akashnetwork/http-sdk": "*", + "@casl/ability": "^6.7.1", "@chain-registry/assets": "^0.7.1", "@cosmjs/amino": "^0.32.4", "@cosmjs/crypto": "^0.32.4", @@ -48,6 +49,7 @@ "@opentelemetry/instrumentation-pino": "^0.41.0", "@opentelemetry/sdk-node": "^0.52.1", "@sentry/node": "^7.55.2", + "@ucast/core": "^1.10.2", "async-sema": "^3.1.1", "axios": "^1.7.2", "commander": "^12.1.0", @@ -3154,6 +3156,17 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@casl/ability": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.1.tgz", + "integrity": "sha512-e+Vgrehd1/lzOSwSqKHtmJ6kmIuZbGBlM2LBS5IuYGGKmVHuhUuyh3XgTn1VIw9+TO4gqU+uptvxfIRBUEdJuw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@celo/base": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@celo/base/-/base-3.2.0.tgz", @@ -19891,6 +19904,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.4.tgz", + "integrity": "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",