diff --git a/.github/workflows/authz_ci.yml b/.github/workflows/authz_ci.yml index 4632fc6f0..e9452a3bb 100644 --- a/.github/workflows/authz_ci.yml +++ b/.github/workflows/authz_ci.yml @@ -17,32 +17,6 @@ jobs: name: Build and test runs-on: ubuntu-latest - - services: - postgres: - image: postgres:14 - ports: - - '5432:5432' - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis - ports: - - '6379:6379' - env: - REDIS_PORT: 6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - name: Checkout uses: actions/checkout@master @@ -62,16 +36,12 @@ jobs: make authz/format/check make authz/lint/check - # TODO: Finish once the authz-node has a database. - name: Setup database and Prisma types shell: bash run: | make authz/copy-default-env - # make orchestration/test/db/setup - - # Generate the orchestration Prisma client types to prevent the type - # tests to fail. - # make orchestration/db/generate-types + make authz/test/db/setup + make authz/db/generate-types - name: Test types shell: bash @@ -86,6 +56,7 @@ jobs: - name: Test integration shell: bash run: | + make authz/test/db/setup make authz/test/integration # - name: Test E2E diff --git a/.gitignore b/.gitignore index 41ccb01c7..560e7208e 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,7 @@ Thumbs.db .env.production .env.test -/rego-build \ No newline at end of file +/rego-build + +*.sqlite +*.sqlite-journal \ No newline at end of file diff --git a/apps/authz/.env.default b/apps/authz/.env.default index 993f8c27f..b57d6e3e8 100644 --- a/apps/authz/.env.default +++ b/apps/authz/.env.default @@ -1 +1,5 @@ +NODE_ENV=development + PORT=3010 + +ENGINE_DATABASE_URL="file:./engine-core.sqlite" diff --git a/apps/authz/.env.test.default b/apps/authz/.env.test.default index b3ec1a628..6f05a0461 100644 --- a/apps/authz/.env.test.default +++ b/apps/authz/.env.test.default @@ -1,2 +1,5 @@ # IMPORTANT: The variables defined here will override other variables. # See `./apps/authz/jest.setup.ts`. +NODE_ENV=test + +ENGINE_DATABASE_URL="file:./engine-core-test.sqlite" diff --git a/apps/authz/Makefile b/apps/authz/Makefile index d93a4a7a8..b666b916d 100644 --- a/apps/authz/Makefile +++ b/apps/authz/Makefile @@ -1,5 +1,6 @@ AUTHZ_PROJECT_NAME := authz AUTHZ_PROJECT_DIR := ./apps/authz +AUTHZ_DATABASE_SCHEMA := ${AUTHZ_PROJECT_DIR}/src/shared/module/persistence/schema/schema.prisma # === Start === @@ -11,6 +12,8 @@ authz/start/dev: authz/setup: make authz/copy-default-env make authz/rego/compile + make authz/db/setup + make authz/test/db/setup authz/copy-default-env: cp ${AUTHZ_PROJECT_DIR}/.env.default ${AUTHZ_PROJECT_DIR}/.env @@ -19,32 +22,71 @@ authz/copy-default-env: # == Code format == authz/format: - npx nx format:write --projects ${AUTHZ_PROJECT_NAME} + npx nx format:write --projects ${AUTHZ_PROJECT_NAME} authz/lint: npx nx lint ${AUTHZ_PROJECT_NAME} -- --fix authz/format/check: - npx nx format:check --projects ${AUTHZ_PROJECT_NAME} + npx nx format:check --projects ${AUTHZ_PROJECT_NAME} authz/lint/check: npx nx lint ${AUTHZ_PROJECT_NAME} +# === Database === + +authz/db/generate-types: + npx prisma generate \ + --schema ${AUTHZ_DATABASE_SCHEMA} + +authz/db/migrate: + npx dotenv -e ${AUTHZ_PROJECT_DIR}/.env -- \ + prisma migrate dev \ + --schema ${AUTHZ_DATABASE_SCHEMA} + +authz/db/setup: + @echo "" + @echo "${TERM_GREEN}🛠️ Setting up Authz development database${TERM_NO_COLOR}" + @echo "" + npx dotenv -e ${AUTHZ_PROJECT_DIR}/.env -- \ + prisma migrate reset \ + --schema ${AUTHZ_DATABASE_SCHEMA} \ + --force + + @echo "" + @echo "${TERM_GREEN}🛠️ Setting up Authz test database${TERM_NO_COLOR}" + @echo "" + make authz/test/db/setup + +authz/db/create-migration: + npx dotenv -e ${AUTHZ_PROJECT_DIR}/.env -- \ + prisma migrate dev \ + --schema ${AUTHZ_DATABASE_SCHEMA} \ + --name ${NAME} + # === Testing === +authz/test/db/setup: + npx dotenv -e ${AUTHZ_PROJECT_DIR}/.env.test --override -- \ + prisma migrate reset \ + --schema ${AUTHZ_DATABASE_SCHEMA} \ + --skip-seed \ + --force + authz/test/type: + make authz/db/generate-types npx tsc \ --project ${AUTHZ_PROJECT_DIR}/tsconfig.app.json \ --noEmit authz/test/unit: - npx nx test:unit ${AUTHZ_PROJECT_NAME} + npx nx test:unit ${AUTHZ_PROJECT_NAME} -- ${ARGS} authz/test/integration: - npx nx test:integration ${AUTHZ_PROJECT_NAME} + npx nx test:integration ${AUTHZ_PROJECT_NAME} -- ${ARGS} authz/test/e2e: - npx nx test:e2e ${AUTHZ_PROJECT_NAME} + npx nx test:e2e ${AUTHZ_PROJECT_NAME} -- ${ARGS} # === Open Policy Agent & Rego === diff --git a/apps/authz/jest.config.ts b/apps/authz/jest.config.ts index 1f3ba9dd4..74bb7f8b4 100644 --- a/apps/authz/jest.config.ts +++ b/apps/authz/jest.config.ts @@ -4,6 +4,7 @@ const config: Config = { displayName: 'authz', moduleFileExtensions: ['ts', 'js', 'html'], preset: '../../jest.preset.js', + setupFiles: ['/jest.setup.ts'], testEnvironment: 'node', transform: { '^.+\\.[tj]s$': [ diff --git a/apps/authz/src/app/app.config.ts b/apps/authz/src/app/app.config.ts index 0ff65ae0f..a72ffc9b3 100644 --- a/apps/authz/src/app/app.config.ts +++ b/apps/authz/src/app/app.config.ts @@ -1,14 +1,28 @@ import { z } from 'zod' +export enum Env { + DEVELOPMENT = 'development', + TEST = 'test', + PRODUCTION = 'production' +} + const ConfigSchema = z.object({ - port: z.coerce.number() + env: z.nativeEnum(Env), + port: z.coerce.number(), + database: z.object({ + url: z.string().startsWith('file:') + }) }) export type Config = z.infer export const load = (): Config => { const result = ConfigSchema.safeParse({ - port: process.env.PORT + env: process.env.NODE_ENV, + port: process.env.PORT, + database: { + url: process.env.ENGINE_DATABASE_URL + } }) if (result.success) { diff --git a/apps/authz/src/app/app.controller.ts b/apps/authz/src/app/app.controller.ts index e9c17bd2b..7ac04cfa3 100644 --- a/apps/authz/src/app/app.controller.ts +++ b/apps/authz/src/app/app.controller.ts @@ -1,5 +1,5 @@ import { EvaluationRequestDto } from '@app/authz/app/evaluation-request.dto' -import { generateInboundRequest } from '@app/authz/shared/module/persistence/mock_data' +import { generateInboundRequest } from '@app/authz/app/persistence/repository/mock_data' import { AuthorizationRequest } from '@narval/authz-shared' import { Body, Controller, Get, Logger, Post } from '@nestjs/common' import { AppService } from './app.service' diff --git a/apps/authz/src/app/app.module.ts b/apps/authz/src/app/app.module.ts index 9483e01e6..532048aee 100644 --- a/apps/authz/src/app/app.module.ts +++ b/apps/authz/src/app/app.module.ts @@ -1,3 +1,4 @@ +import { OrganizationRepository } from '@app/authz/app/persistence/repository/organization.repository' import { PersistenceModule } from '@app/authz/shared/module/persistence/persistence.module' import { Logger, Module, OnApplicationBootstrap, ValidationPipe } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' @@ -10,13 +11,15 @@ import { OpaService } from './opa/opa.service' @Module({ imports: [ ConfigModule.forRoot({ - load: [load] + load: [load], + isGlobal: true }), PersistenceModule ], controllers: [AppController], providers: [ AppService, + OrganizationRepository, OpaService, { provide: APP_PIPE, diff --git a/apps/authz/src/app/app.service.ts b/apps/authz/src/app/app.service.ts index b946252a3..ee37ed953 100644 --- a/apps/authz/src/app/app.service.ts +++ b/apps/authz/src/app/app.service.ts @@ -1,4 +1,4 @@ -import { PersistenceRepository } from '@app/authz/shared/module/persistence/persistence.repository' +import { OrganizationRepository } from '@app/authz/app/persistence/repository/organization.repository' import { AuthCredential, OpaResult, RegoInput } from '@app/authz/shared/types/domain.type' import { Action, @@ -61,11 +61,11 @@ export const finalizeDecision = (response: OpaResult[]) => { @Injectable() export class AppService { - constructor(private persistenceRepository: PersistenceRepository, private opaService: OpaService) {} + constructor(private OrganizationRepository: OrganizationRepository, private opaService: OpaService) {} async #verifySignature(requestSignature: Signature, verificationMessage: string): Promise { const { pubKey, alg, sig } = requestSignature - const credential = await this.persistenceRepository.getCredentialForPubKey(pubKey) + const credential = await this.OrganizationRepository.getCredentialForPubKey(pubKey) if (alg === Alg.ES256K) { // TODO: ensure sig & pubkey begins with 0x const signature = sig.startsWith('0x') ? sig : `0x${sig}` diff --git a/apps/authz/src/app/opa/opa.service.ts b/apps/authz/src/app/opa/opa.service.ts index be2511f89..f3d95cac1 100644 --- a/apps/authz/src/app/opa/opa.service.ts +++ b/apps/authz/src/app/opa/opa.service.ts @@ -1,4 +1,4 @@ -import { PersistenceRepository } from '@app/authz/shared/module/persistence/persistence.repository' +import { OrganizationRepository } from '@app/authz/app/persistence/repository/organization.repository' import { OpaResult, RegoInput } from '@app/authz/shared/types/rego' import { Injectable, Logger } from '@nestjs/common' import { loadPolicy } from '@open-policy-agent/opa-wasm' @@ -15,7 +15,7 @@ export class OpaService { private logger = new Logger(OpaService.name) private opaEngine: OpaEngine | undefined - constructor(private persistenceRepository: PersistenceRepository) {} + constructor(private organizationRepository: OrganizationRepository) {} async onApplicationBootstrap(): Promise { this.logger.log('OPA Service boot') @@ -34,7 +34,7 @@ export class OpaService { const opaEngine = await loadPolicy(policyWasm, undefined, { 'time.now_ns': () => new Date().getTime() * 1000000 }) - const data = await this.persistenceRepository.getEntityData() + const data = await this.organizationRepository.getEntityData() opaEngine.setData(data) return opaEngine } diff --git a/apps/authz/src/app/persistence/repository/__test__/integration/organization.repository.spec.ts b/apps/authz/src/app/persistence/repository/__test__/integration/organization.repository.spec.ts new file mode 100644 index 000000000..819103f05 --- /dev/null +++ b/apps/authz/src/app/persistence/repository/__test__/integration/organization.repository.spec.ts @@ -0,0 +1,56 @@ +import { load } from '@app/authz/app/app.config' +import { OrganizationRepository } from '@app/authz/app/persistence/repository/organization.repository' +import { PersistenceModule } from '@app/authz/shared/module/persistence/persistence.module' +import { TestPrismaService } from '@app/authz/shared/module/persistence/service/test-prisma.service' +import { Alg } from '@narval/authz-shared' +import { ConfigModule, ConfigService } from '@nestjs/config' +import { Test, TestingModule } from '@nestjs/testing' + +describe(OrganizationRepository.name, () => { + let module: TestingModule + let repository: OrganizationRepository + let testPrismaService: TestPrismaService + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [load], + isGlobal: true + }), + PersistenceModule + ], + providers: [OrganizationRepository] + }).compile() + + testPrismaService = module.get(TestPrismaService) + repository = module.get(OrganizationRepository) + }) + + afterEach(async () => { + await testPrismaService.truncateAll() + await module.close() + }) + + describe('create', () => { + it('should have test db url', () => { + // Just leaving this to ensure the jest.setup.ts file is configured correctly to set the env variable. + const configService = module.get(ConfigService) + expect(configService.get('database.url', { infer: true })).toBe('file:./engine-core-test.sqlite') + }) + + it('creates a new organization', async () => { + await repository.createOrganization('test-org-uid', 'test-user-uid', { + alg: Alg.ES256K, + pubKey: 'test-public-key' + }) + + const org = await testPrismaService.getClient().organization.findFirst({ + where: { + uid: 'test-org-uid' + } + }) + expect(org).toEqual({ uid: 'test-org-uid' }) + }) + }) +}) diff --git a/apps/authz/src/shared/module/persistence/mock_data.ts b/apps/authz/src/app/persistence/repository/mock_data.ts similarity index 99% rename from apps/authz/src/shared/module/persistence/mock_data.ts rename to apps/authz/src/app/persistence/repository/mock_data.ts index f64d61645..b8935fff7 100644 --- a/apps/authz/src/shared/module/persistence/mock_data.ts +++ b/apps/authz/src/app/persistence/repository/mock_data.ts @@ -47,7 +47,6 @@ export const MATT: User = { } export const MATT_CREDENTIAL_1: AuthCredential = { - id: 'credentialId1', alg: Alg.ES256K, userId: MATT.uid, pubKey: '0xd75D626a116D4a1959fE3bB938B2e7c116A05890' @@ -60,7 +59,6 @@ export const AAUser: User = { export const AAUser_Credential_1: AuthCredential = { userId: AAUser.uid, - id: 'credentialId2', alg: Alg.ES256K, pubKey: '0x501D5c2Ce1EF208aadf9131a98BAa593258CfA06' } @@ -72,7 +70,6 @@ export const BBUser: User = { export const BBUser_Credential_1: AuthCredential = { userId: BBUser.uid, - id: 'credentialId3', alg: Alg.ES256K, pubKey: '0xab88c8785D0C00082dE75D801Fcb1d5066a6311e' } diff --git a/apps/authz/src/app/persistence/repository/organization.repository.ts b/apps/authz/src/app/persistence/repository/organization.repository.ts new file mode 100644 index 000000000..cc228cfcb --- /dev/null +++ b/apps/authz/src/app/persistence/repository/organization.repository.ts @@ -0,0 +1,152 @@ +import { PrismaService } from '@app/authz/shared/module/persistence/service/prisma.service' +import { AccountType, AuthCredential, UserRoles } from '@app/authz/shared/types/domain.type' +import { Address } from '@narval/authz-shared' +import { Injectable, Logger, OnModuleInit } from '@nestjs/common' +import { mockEntityData, userAddressStore, userCredentialStore } from './mock_data' + +// Input types; should become DTOs +type AuthCredentialDto = Omit + +@Injectable() +export class OrganizationRepository implements OnModuleInit { + private logger = new Logger(OrganizationRepository.name) + + constructor(private prismaService: PrismaService) {} + + async onModuleInit() { + this.logger.log('OrganizationRepository initialized') + } + + async getEntityData() { + const data = mockEntityData + return data + } + + async getUserForAddress(address: string): Promise { + const userId = userAddressStore[address] + if (!userId) throw new Error(`Could not find user for address ${address}`) + return userId + } + + async getCredentialForPubKey(pubKey: string): Promise { + const credential = userCredentialStore[pubKey] + if (!credential) throw new Error(`Could not find credential for pubKey ${pubKey}`) + return credential + } + + // CRUD + + async createOrganization(organizationId: string, rootUserId: string, credential: AuthCredentialDto) { + const organization = await this.prismaService.organization.create({ + data: { + uid: organizationId + } + }) + this.logger.log(`Created organization ${organization.uid}`) + + const rootUser = await this.prismaService.user.create({ + data: { + uid: rootUserId, + role: UserRoles.ROOT + } + }) + + this.logger.log(`Created Root User ${rootUser.uid}`) + + const rootAuthCredential = await this.prismaService.authCredential.create({ + data: { + pubKey: credential.pubKey, + alg: credential.alg, + userId: rootUserId + } + }) + + this.logger.log(`Created Root User AuthCredential ${rootAuthCredential.pubKey}`) + + // TODO: Persist the API key -- is API Key tied to user, org, credential, or 1:n to user? + return 'api-key' + } + + async createUser(uid: string, credential: AuthCredentialDto, role: UserRoles) { + // Create the User with the Role + // Create the user's Credential + await this.prismaService.user.create({ + data: { + uid, + role + } + }) + + await this.prismaService.authCredential.create({ + data: { + pubKey: credential.pubKey, + alg: credential.alg, + userId: uid + } + }) + } + + async deleteUser(uid: string) { + // Delete the User + // Delete the user's Credentials + // Remove the user as an assignee of any wallets/groups + await this.prismaService.user.delete({ + where: { + uid + } + }) + await this.prismaService.authCredential.deleteMany({ + where: { + userId: uid + } + }) + // TODO: remove user from any wallets/groups + } + + // eslint-disable-next-line + async registerWallet(uid: string, address: Address, accountType: AccountType, chainId?: number) {} + + async registerRootKey() {} +} + +/* +CREATE_ORGANIZATION - only have 1 currently + +REGISTER_ROOT_KEY - an underlying Vault Service/root key that can derive wallets; necessary for CREATE_WALLET action + +REGISTER_WALLET - register an existing wallet with the system + +CREATE_USER + +DELETE_USER + +CREATE_USER_GROUP + +UPDATE_USER_GROUP + +DELETE_USER_GROUP + +CREATE_WALLET_GROUP + +UPDATE_WALLET_GROUP + +DELETE_WALLET_GROUP + +ASSIGN_USER_ROLE + +ASSIGN_USER_GROUP + +ASSIGN_WALLET_GROUP + +ADD_USER_AUTHN + +REMOVE_USER_AUTHN + +SET_POLICY_RULES + +UPDATE_ADDRESS_BOOK + +GENERATE_API_KEY ??? + +REVOKE_API_KEY ??? +*/ diff --git a/apps/authz/src/shared/module/persistence/persistence.module.ts b/apps/authz/src/shared/module/persistence/persistence.module.ts index caa94f8de..6fdf19ecf 100644 --- a/apps/authz/src/shared/module/persistence/persistence.module.ts +++ b/apps/authz/src/shared/module/persistence/persistence.module.ts @@ -1,8 +1,9 @@ +import { PrismaService } from '@app/authz/shared/module/persistence/service/prisma.service' +import { TestPrismaService } from '@app/authz/shared/module/persistence/service/test-prisma.service' import { Module } from '@nestjs/common' -import { PersistenceRepository } from './persistence.repository' @Module({ - exports: [PersistenceRepository], - providers: [PersistenceRepository] + exports: [PrismaService, TestPrismaService], + providers: [PrismaService, TestPrismaService] }) export class PersistenceModule {} diff --git a/apps/authz/src/shared/module/persistence/persistence.repository.ts b/apps/authz/src/shared/module/persistence/persistence.repository.ts deleted file mode 100644 index 4dc74c3ef..000000000 --- a/apps/authz/src/shared/module/persistence/persistence.repository.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { AuthCredential } from '@app/authz/shared/types/domain.type' -import { Injectable, Logger, OnModuleInit } from '@nestjs/common' -import { mockEntityData, userAddressStore, userCredentialStore } from './mock_data' - -@Injectable() -export class PersistenceRepository implements OnModuleInit { - private logger = new Logger(PersistenceRepository.name) - - async onModuleInit() { - this.logger.log('PersistenceRepository initialized') - } - - async getEntityData() { - const data = mockEntityData - return data - } - - async getUserForAddress(address: string): Promise { - const userId = userAddressStore[address] - if (!userId) throw new Error(`Could not find user for address ${address}`) - return userId - } - - async getCredentialForPubKey(pubKey: string): Promise { - const credential = userCredentialStore[pubKey] - if (!credential) throw new Error(`Could not find credential for pubKey ${pubKey}`) - return credential - } -} diff --git a/apps/authz/src/shared/module/persistence/schema/migrations/20240129224539_init/migration.sql b/apps/authz/src/shared/module/persistence/schema/migrations/20240129224539_init/migration.sql new file mode 100644 index 000000000..ccc642745 --- /dev/null +++ b/apps/authz/src/shared/module/persistence/schema/migrations/20240129224539_init/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "organization" ( + "uid" TEXT NOT NULL PRIMARY KEY +); + +-- CreateTable +CREATE TABLE "auth_credential" ( + "pub_key" TEXT NOT NULL PRIMARY KEY, + "alg" TEXT NOT NULL, + "user_id" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "user" ( + "uid" TEXT NOT NULL PRIMARY KEY, + "role" TEXT NOT NULL +); diff --git a/apps/authz/src/shared/module/persistence/schema/migrations/migration_lock.toml b/apps/authz/src/shared/module/persistence/schema/migrations/migration_lock.toml new file mode 100644 index 000000000..e5e5c4705 --- /dev/null +++ b/apps/authz/src/shared/module/persistence/schema/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/apps/authz/src/shared/module/persistence/schema/schema.prisma b/apps/authz/src/shared/module/persistence/schema/schema.prisma new file mode 100644 index 000000000..ba44dfadc --- /dev/null +++ b/apps/authz/src/shared/module/persistence/schema/schema.prisma @@ -0,0 +1,34 @@ +generator client { + provider = "prisma-client-js" + // Output into a separate subdirectory so multiple schemas can be used in a + // monorepo. + // + // Reference: https://github.com/nrwl/nx-recipes/tree/main/nestjs-prisma + output = "../../../../../../../node_modules/@prisma/client/authz" +} + +datasource db { + provider = "sqlite" + url = env("ENGINE_DATABASE_URL") +} + +model Organization { + uid String @id + + @@map("organization") +} + +model AuthCredential { + pubKey String @id @map("pub_key") + alg String @map("alg") + userId String @map("user_id") + + @@map("auth_credential") +} + +model User { + uid String @id + role String + + @@map("user") +} diff --git a/apps/authz/src/shared/module/persistence/seed.ts b/apps/authz/src/shared/module/persistence/seed.ts new file mode 100644 index 000000000..6ee285456 --- /dev/null +++ b/apps/authz/src/shared/module/persistence/seed.ts @@ -0,0 +1,34 @@ +import { Logger } from '@nestjs/common' +import { Organization, PrismaClient } from '@prisma/client/authz' + +const prisma = new PrismaClient() + +const orgs: Organization[] = [ + { + uid: '7d704a62-d15e-4382-a826-1eb41563043b' + } +] + +async function main() { + const logger = new Logger('EngineSeed') + + logger.log('Seeding Engine database') + + for (const org of orgs) { + await prisma.organization.create({ + data: org + }) + } + + logger.log('Engine database germinated 🌱') +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/apps/authz/src/shared/module/persistence/service/__test__/unit/prisma.service.spec.ts b/apps/authz/src/shared/module/persistence/service/__test__/unit/prisma.service.spec.ts new file mode 100644 index 000000000..c5f079084 --- /dev/null +++ b/apps/authz/src/shared/module/persistence/service/__test__/unit/prisma.service.spec.ts @@ -0,0 +1,17 @@ +import { PrismaService } from '@app/authz/shared/module/persistence/service/prisma.service' +import { ConfigService } from '@nestjs/config' +import { mock } from 'jest-mock-extended' + +describe(PrismaService.name, () => { + describe('constructor', () => { + it('does not throw when ENGINE_DATABASE_URL is present', () => { + const configServiceMock = mock({ + get: jest.fn().mockReturnValue('file:./engine-core-test.sqlite') + }) + + expect(() => { + new PrismaService(configServiceMock) + }).not.toThrow() + }) + }) +}) diff --git a/apps/authz/src/shared/module/persistence/service/prisma.service.ts b/apps/authz/src/shared/module/persistence/service/prisma.service.ts new file mode 100644 index 000000000..d830e73a1 --- /dev/null +++ b/apps/authz/src/shared/module/persistence/service/prisma.service.ts @@ -0,0 +1,54 @@ +import { Config } from '@app/authz/app/app.config' +import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { PrismaClient } from '@prisma/client/authz' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy, OnApplicationShutdown { + private logger = new Logger(PrismaService.name) + + constructor(@Inject(ConfigService) configService: ConfigService) { + const url = configService.get('database.url', { infer: true }) + + super({ + datasources: { + db: { url } + } + }) + } + + async onModuleInit() { + this.logger.log({ + message: 'Connecting to Prisma on database module initialization' + }) + + await this.$connect() + } + + async onModuleDestroy() { + this.logger.log({ + message: 'Disconnecting from Prisma on module destroy' + }) + + await this.$disconnect() + } + + // In Prisma v5, the `beforeExit` is no longer available. Instead, we use + // NestJS' application shutdown to disconnect from the database. The shutdown + // hooks are called when the process receives a termination event lig SIGhooks + // are called when the process receives a termination event lig SIGTERM. + // + // See also https://www.prisma.io/docs/guides/upgrade-guides/upgrading-versions/upgrading-to-prisma-5#removal-of-the-beforeexit-hook-from-the-library-engine + onApplicationShutdown(signal: string) { + this.logger.log({ + message: 'Disconnecting from Prisma on application shutdown', + signal + }) + + // The $disconnect method returns a promise, so idealy we should wait for it + // to finish. However, the onApplicationShutdown, returns `void` making it + // impossible to ensure the database will be properly disconnected before + // the shutdown. + this.$disconnect() + } +} diff --git a/apps/authz/src/shared/module/persistence/service/test-prisma.service.ts b/apps/authz/src/shared/module/persistence/service/test-prisma.service.ts new file mode 100644 index 000000000..f9f66bef7 --- /dev/null +++ b/apps/authz/src/shared/module/persistence/service/test-prisma.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common' +import { PrismaClient } from '@prisma/client/authz' +import { PrismaService } from './prisma.service' + +@Injectable() +export class TestPrismaService { + constructor(private prisma: PrismaService) {} + + getClient(): PrismaClient { + return this.prisma + } + + async truncateAll(): Promise { + const tablenames = await this.prisma.$queryRaw< + Array<{ name: string }> + >`SELECT name FROM sqlite_master WHERE type='table'` + + for (const { name } of tablenames) { + if (name !== '_prisma_migrations') { + try { + await this.prisma.$executeRawUnsafe( + `DELETE FROM ${name}; UPDATE SQLITE_SEQUENCE SET seq = 0 WHERE name = '${name}';` + ) + } catch (error) { + // The logger may be intentionally silented during tests. Thus, we use + // console.log to ensure engineers will see the error in the stdout. + // + // eslint-disable-next-line no-console + console.error('TestPrismaService DELETE error', error) + } + } + } + } +} diff --git a/apps/authz/src/shared/types/domain.type.ts b/apps/authz/src/shared/types/domain.type.ts index 09c82ee63..d16b4b251 100644 --- a/apps/authz/src/shared/types/domain.type.ts +++ b/apps/authz/src/shared/types/domain.type.ts @@ -60,7 +60,6 @@ export type VerifiedApproval = { } export type AuthCredential = { - id: string pubKey: string alg: Alg userId: string