diff --git a/.e2e-micro.env b/.e2e-micro.env index 866e5ea8..4a7fa279 100644 --- a/.e2e-micro.env +++ b/.e2e-micro.env @@ -1,2 +1,3 @@ DB_LOGGING=0 ORM_TYPE=microorm +PORT=3000 diff --git a/.e2e.env b/.e2e.env index eda3206d..083b55df 100644 --- a/.e2e.env +++ b/.e2e.env @@ -1,2 +1,3 @@ DB_LOGGING=0 ORM_TYPE=typeorm +PORT=3000 diff --git a/.env b/.env index eea2869d..478baed2 100644 --- a/.env +++ b/.env @@ -1,17 +1,4 @@ DB_HOST=localhost DB_LOGGING=1 -DB_NAME="json-api-db" -DB_USERNAME="postgres" -DB_PASSWORD="postgres" -DB_PORT=5432 -DB_TYPE=postgres - -#DB_USERNAME="root" -#DB_PASSWORD="mysql" -#DB_PORT=3306 -#DB_TYPE=mysql - - -#ORM_TYPE=microorm -ORM_TYPE=typeorm +USE_ATTACH_BUFFER=true diff --git a/.github/actions/bump-version-save.yml b/.github/actions/bump-version-save.yml index 25f4eb35..f5bcb68a 100644 --- a/.github/actions/bump-version-save.yml +++ b/.github/actions/bump-version-save.yml @@ -5,7 +5,7 @@ on: projects: description: 'Package Bump' required: true - default: 'json-api-nestjs,json-api-nestjs-sdk,nestjs-json-rpc,nestjs-json-rpc-sdk' + default: 'json-api-nestjs,json-api-nestjs-sdk,nestjs-json-rpc,nestjs-json-rpc-sdk,nestjs-acl-permissions' first-release: description: 'Is first release?' required: false diff --git a/.github/workflows/e2e-test-mikroorm.yml b/.github/workflows/e2e-test-mikroorm.yml new file mode 100644 index 00000000..ea57adf4 --- /dev/null +++ b/.github/workflows/e2e-test-mikroorm.yml @@ -0,0 +1,65 @@ +name: ⚙️ E2E Test + + +on: + workflow_call: + inputs: + mainBranch: + description: Type for main nx affect + required: false + default: "master" + type: string + secrets: + NX_CLOUD_ACCESS_TOKEN: + required: true + +env: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + + +jobs: + e2e-test: + runs-on: ubuntu-latest +# services: +# postgres: +# image: postgres +# env: +# POSTGRES_PASSWORD: postgres +# POSTGRES_DB: json-api-db +# options: >- +# --health-cmd pg_isready +# --health-interval 10s +# --health-timeout 5s +# --health-retries 5 +# ports: +# - 5432:5432 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: ./.github/actions + + - name: Determine base for NX affected (if not master) + if: ${{ inputs.mainBranch != 'master' }} + shell: bash + run: | + LAST_TAG=$(git describe --tags --abbrev=0) + LAST_TAG_HASH=$(git rev-parse "$LAST_TAG") + CURRENT_COMMIT=$(git rev-parse HEAD) + echo "Using last tag hash as NX_BASE: $LAST_TAG_HASH" + echo "Using current commit as NX_HEAD: $CURRENT_COMMIT" + echo "NX_BASE=$LAST_TAG_HASH" >> $GITHUB_ENV + echo "NX_HEAD=$CURRENT_COMMIT" >> $GITHUB_ENV + + - name: Determine base for NX affected (if master) + if: ${{ inputs.mainBranch == 'master' }} + uses: nrwl/nx-set-shas@v4 + with: + main-branch-name: ${{ inputs.mainBranch }} + +# - run: npm run typeorm migration:run +# - run: npm run seed:run +# - run: npx nx run json-api-server-e2e:e2e --parallel=1 + - run: npx nx run json-api-server-e2e:e2e-micro --parallel=1 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index cfce24dd..c3c09e58 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -20,19 +20,19 @@ env: jobs: e2e-test: runs-on: ubuntu-latest - services: - postgres: - image: postgres - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: json-api-db - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 +# services: +# postgres: +# image: postgres +# env: +# POSTGRES_PASSWORD: postgres +# POSTGRES_DB: json-api-db +# options: >- +# --health-cmd pg_isready +# --health-interval 10s +# --health-timeout 5s +# --health-retries 5 +# ports: +# - 5432:5432 steps: - name: Checkout uses: actions/checkout@v4 @@ -59,7 +59,7 @@ jobs: with: main-branch-name: ${{ inputs.mainBranch }} - - run: npm run typeorm migration:run - - run: npm run seed:run +# - run: npm run typeorm migration:run +# - run: npm run seed:run - run: npx nx run json-api-server-e2e:e2e --parallel=1 - - run: npx nx run json-api-server-e2e:e2e-micro --parallel=1 +# - run: npx nx run json-api-server-e2e:e2e-micro --parallel=1 diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml index 35a04a6b..4a48e880 100644 --- a/.github/workflows/new-release.yml +++ b/.github/workflows/new-release.yml @@ -13,6 +13,7 @@ on: - tag:type:publish - tag:lib:json-api-nestjs - tag:lib:nestjs-json-rpc + - lib:nestjs-acl-permissions project1: description: "Select project" required: false @@ -27,6 +28,7 @@ on: - json-api-nestjs-microorm - json-api-nestjs-shared - json-api-nestjs-typeorm + - nestjs-acl-permissions project2: description: "Select project" required: false @@ -41,6 +43,7 @@ on: - json-api-nestjs-microorm - json-api-nestjs-shared - json-api-nestjs-typeorm + - nestjs-acl-permissions project3: description: "Select project" required: false @@ -55,6 +58,7 @@ on: - json-api-nestjs-microorm - json-api-nestjs-shared - json-api-nestjs-typeorm + - nestjs-acl-permissions project4: description: "Select project" required: false @@ -69,6 +73,7 @@ on: - json-api-nestjs-microorm - json-api-nestjs-shared - json-api-nestjs-typeorm + - nestjs-acl-permissions beta-release: description: 'Is beta release?' required: false @@ -106,6 +111,13 @@ jobs: mainBranch: "last-tag" secrets: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + e2e-test-mikroorm: + needs: [ test ] + uses: ./.github/workflows/e2e-test-mikroorm.yml + with: + mainBranch: "last-tag" + secrets: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} build: needs: [test, e2e-test] diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index beac577e..2ea39d09 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,8 +15,14 @@ jobs: secrets: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + e2e-test-mikroorm: + needs: [ test ] + uses: ./.github/workflows/e2e-test-mikroorm.yml + secrets: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + build: - needs: [test, e2e-test] + needs: [test, e2e-test, e2e-test-mikroorm] uses: ./.github/workflows/build.yml secrets: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} diff --git a/.test.env b/.test.env deleted file mode 100644 index 4f233b1b..00000000 --- a/.test.env +++ /dev/null @@ -1,2 +0,0 @@ -NODE_OPTIONS=--experimental-vm-modules --disable-warning=ExperimentalWarning -DB_LOGGING=0 diff --git a/.vscode/launch.json b/.vscode/launch.json index 654eeee6..7ad6f57c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,24 @@ "${workspaceFolder}/apps/json-api-server/dist/**/*.(m|c|)js", "!**/node_modules/**" ] + }, + { + "type": "node", + "request": "launch", + "name": "Debug test with Nx", + "runtimeExecutable": "npx", + "runtimeArgs": ["nx", "serve", "test"], + "env": { + "NODE_OPTIONS": "--inspect=9230" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["/**"], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/apps/test/dist/**/*.(m|c|)js", + "!**/node_modules/**" + ] } ] } diff --git a/README.md b/README.md index 5f58d33f..9f55c982 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,293 @@ +

Smart Tools for NestJS

+

-NestJS JSON API & JSON RPC Suite + A comprehensive monorepo of NestJS libraries for building standardized APIs with JSON:API and JSON-RPC 2.0 specifications, with fine-grained access control and resource permissions.

-

- This monorepo contains a set of several libraries designed to simplify the development of server and client applications using NestJS. These tools help you work with two popular protocols: -

+--- + +## 📚 Table of Contents + +- [Overview](#-overview) +- [Prerequisites](#-prerequisites) +- [Packages](#-packages) + - [JSON:API](#1-jsonapi) + - [JSON-RPC](#2-json-rpc) + - [Access Control (ACL)](#3-access-control-acl) +- [Quick Start](#-quick-start) + - [Installation](#installation) + - [Running Demo Applications](#running-demo-applications) +- [License](#-license) + +--- + +## 🎯 Overview + +This monorepo provides a complete set of tools to simplify the development of server and client applications using **NestJS**. It supports two popular protocols: + +- **[JSON:API](https://jsonapi.org/)** – Build RESTful APIs with standardized request/response formats +- **[JSON-RPC 2.0](https://www.jsonrpc.org/)** – Implement remote procedure calls using JSON +- **[CASL](https://casl.js.org/)** – Fine-grained access control with full integration for JSON:API resources + +All packages are designed to work seamlessly with modern ORMs like **TypeORM** and **MikroORM**, and include built-in support for [PGlite](https://github.com/electric-sql/pglite) for local development. + +--- + +## 📋 Prerequisites + +- **Node.js** >= 20.0.0 +- **npm** or **yarn** + +--- + +## 📦 Packages + +### 1. JSON:API + +Build production-ready **JSON:API** compliant REST APIs with automatic CRUD generation, filtering, sorting, pagination, and relationship handling. + + + + + + + + + + + + + + + + + + + + + + + + + +
PackageDescription
+json-api-nestjs + +Core library for creating JSON:API compliant servers. Automatically generates endpoints for CRUD operations, relationships, filtering, sorting, pagination, and atomic operations. Supports TypeORM and MikroORM adapters. +
+json-api-nestjs-typeorm + +TypeORM adapter for json-api-nestjs. Enables JSON:API functionality with TypeORM entities, migrations, and repositories. +
+json-api-nestjs-microorm + +MikroORM adapter for json-api-nestjs. Provides JSON:API support with MikroORM entities, migrations, and advanced query features. +
+json-api-nestjs-sdk + +Type-safe client SDK for consuming JSON:API endpoints. Works with Axios, Fetch API, and Angular HttpClient. Supports filtering, sorting, includes, atomic operations, and provides full TypeScript type inference. +
+ +--- + +### 2. JSON-RPC + +Implement **JSON-RPC 2.0** servers and clients with support for HTTP and WebSocket transports, batch requests, and automatic method discovery. + + + + + + + + + + + + + + + + +
PackageDescription
+nestjs-json-rpc + +JSON-RPC 2.0 server implementation for NestJS. Supports HTTP and WebSocket transports, batch requests, custom error handling, and automatic method registration via decorators. +
+nestjs-json-rpc-sdk + +Type-safe JSON-RPC client SDK with automatic method inference, batch request support, and WebSocket/HTTP transport options. +
+ +--- + +### 3. Access Control (ACL) + +Add fine-grained **Access Control Lists** to your JSON:API endpoints using **[CASL](https://casl.js.org/)** with template-based rule materialization. + + + + + + + + + + + +
PackageDescription
+nestjs-acl-permissions + +Type-safe ACL module with CASL integration for JSON:API endpoints. Features template interpolation, field-level permissions, context-based rules, lazy evaluation, and transparent ORM-level filtering. Can be used standalone or with automatic integration via json-api-nestjs. +
+ +--- + +## 🚀 Quick Start + +### Installation + +This monorepo uses [Nx](https://nx.dev/) and supports **TypeORM** and **MikroORM** with [PGlite](https://github.com/electric-sql/pglite) for local development. + +```bash +# Install dependencies +npm install +``` -- **[JSON:API](https://jsonapi.org/)** – A specification for building RESTful APIs with standardized request and response formats. - - > **[json-api-nestjs](https://github.com/klerick/nestjs-json-api/tree/master/libs/json-api/json-api-nestjs)** - This package enables you to quickly set up a server API that adheres to the JSON:API specification, handling standard CRUD operations for your resources.
- > **[json-api-nestjs-microorm](https://github.com/klerick/nestjs-json-api/tree/master/libs/json-api/json-api-nestjs-microorm)** - This package is adapter for MicroOrm.
- > **[json-api-nestjs-typeorm](https://github.com/klerick/nestjs-json-api/tree/master/libs/json-api/json-api-nestjs-typeorm)** - This package is adapter for TypeOrm.
- > **[json-api-nestjs-sdk](https://github.com/klerick/nestjs-json-api/tree/master/libs/json-api/json-api-nestjs-sdk)** - tool for client, call api over *json-api-nestjs* +#### Setup TypeORM Database +```bash +# Initialize database and run migrations +npm run typeorm:up:remove # Removes existing DB and runs migrations -- **[JSON-RPC](https://www.jsonrpc.org/)** – A protocol for remote procedure calls using JSON. +# Or just run migrations (if DB exists) +npm run typeorm:up -> **[nestjs-json-rpc](https://github.com/klerick/nestjs-json-api/tree/master/libs/json-rpc/nestjs-json-rpc)** - Use this package to implement remote procedure call (RPC) functionality in your NestJS applications, enabling efficient inter-service communication.
-> **[nestjs-json-rpc-sdk](https://github.com/klerick/nestjs-json-api/tree/master/libs/json-rpc/nestjs-json-rpc-sdk)** - This tool offers a straightforward way to call remote procedures from your client-side code, ensuring smooth communication with your JSON-RPC server. +# Seed the database +npm run typeorm:seeder +``` -- **ACL tools** - tool for acl over *json-api-nestjs*(coming soon...) -## Installation +#### Setup MikroORM Database ```bash -$ npm install -$ npm run typeorm:run -$ npm run seed:run +# Initialize database and run migrations +npm run microorm:up:remove # Removes existing DB and runs migrations + +# Or just run migrations (if DB exists) +npm run microorm:up + +# Seed the database +npm run microorm:seeder ``` -## Running the example app +--- + +### Running Demo Applications + +#### JSON:API Server (TypeORM) ```bash -# dev server -$ nx run json-api-server:serve:development +# Using npm script +npm run demo:json-api +# Or using nx directly +nx run json-api-server:serve-typeorm ``` -## License -The plugin is [MIT licensed](LICENSE). +Server will start on `http://localhost:3000` (or configured port) + +Available endpoints: +- `GET /api/users` - List all users +- `GET /api/users/:id` - Get single user +- `POST /api/users` - Create user +- `PATCH /api/users/:id` - Update user +- `DELETE /api/users/:id` - Delete user +- `GET /api/users/:id/relationships/:rel` - Get relationships +- And more... + +#### JSON:API Server (MikroORM) + +```bash +# Using nx +nx run json-api-server:serve-microorm +``` + +--- + +## 🌟 Features + +- **Automatic CRUD Generation** – Generate complete REST APIs from ORM entities +- **JSON:API Compliant** – Full specification support including relationships, filtering, sorting, pagination, sparse fieldsets +- **Atomic Operations** – Perform multiple operations in a single request +- **Type Safety** – Full TypeScript support with type inference +- **Multiple ORMs** – Support for TypeORM and MikroORM +- **JSON-RPC 2.0** – Implement RPC servers with HTTP/WebSocket transports +- **Access Control** – Fine-grained permissions with CASL integration +- **Swagger/OpenAPI** – Automatic API documentation generation +- **Extensible** – Override default controllers and services + +--- + +## 📖 Documentation + +Each package has detailed documentation in its own README: + +- [json-api-nestjs](libs/json-api/json-api-nestjs/README.md) +- [json-api-nestjs-typeorm](libs/json-api/json-api-nestjs-typeorm/README.md) +- [json-api-nestjs-microorm](libs/json-api/json-api-nestjs-microorm/README.md) +- [json-api-nestjs-sdk](libs/json-api/json-api-nestjs-sdk/README.md) +- [nestjs-json-rpc](libs/json-rpc/nestjs-json-rpc/README.md) +- [nestjs-json-rpc-sdk](libs/json-rpc/nestjs-json-rpc-sdk/README.md) +- [nestjs-acl-permissions](libs/acl-permissions/nestjs-acl-permissions/README.md) + +--- + +## 📘 Examples & Usage + +For detailed usage examples and real-world scenarios, refer to the comprehensive **E2E test suites**. These tests serve as living documentation and demonstrate best practices: + +### JSON:API SDK Examples +Learn how to use the JSON:API client SDK with various operations: + +- **[GET Operations](apps/json-api-server-e2e/src/json-api/json-api-sdk/get-method.spec.ts)** – Fetching resources, filtering, pagination, sparse fieldsets, and relationships +- **[POST Operations](apps/json-api-server-e2e/src/json-api/json-api-sdk/post-method.spec.ts)** – Creating resources with relationships +- **[PATCH Operations](apps/json-api-server-e2e/src/json-api/json-api-sdk/patch-methode.spec.ts)** – Updating resources and relationships +- **[Atomic Operations](apps/json-api-server-e2e/src/json-api/json-api-sdk/atomic-sdk.spec.ts)** – Batch requests with multiple operations +- **[Advanced Configuration](apps/json-api-server-e2e/src/json-api/json-api-sdk/check-othe-call.spec.ts)** – Custom routes, UUID IDs, validation pipes +- **[Common Decorators](apps/json-api-server-e2e/src/json-api/json-api-sdk/check-common-decorator.spec.ts)** – Guards, interceptors, and exception filters + +### JSON-RPC Examples +Explore JSON-RPC 2.0 client usage patterns: + +- **[HTTP Transport](apps/json-api-server-e2e/src/json-api/json-rpc/run-json-rpc.spec.ts)** – Single and batch RPC calls over HTTP, error handling +- **[WebSocket Transport](apps/json-api-server-e2e/src/json-api/json-rpc/run-ws-json-rpc.spec.ts)** – Real-time RPC over WebSocket connections + +### Access Control (ACL) Examples +Understand fine-grained permission enforcement with CASL integration: + +- **[GET All Resources](apps/json-api-server-e2e/src/json-api/json-acl/1-get-all-acl-check.spec.ts)** – Field-level and row-level filtering +- **[GET One Resource](apps/json-api-server-e2e/src/json-api/json-acl/2-get-one-acl-check.spec.ts)** – Resource-level access control +- **[GET Relationships](apps/json-api-server-e2e/src/json-api/json-acl/3-get-relationship-acl-check.spec.ts)** – Relationship endpoint permissions +- **[PATCH Operations](apps/json-api-server-e2e/src/json-api/json-acl/4-patch-one-acl-check.spec.ts)** – Update permissions with field and value restrictions +- **[POST Operations](apps/json-api-server-e2e/src/json-api/json-acl/5-post-one-acl-check.spec.ts)** – Create permissions with conditional validation +- **[DELETE Operations](apps/json-api-server-e2e/src/json-api/json-acl/6-delete-one-acl-check.spec.ts)** – Conditional delete based on resource state +- **[Atomic Operations ACL](apps/json-api-server-e2e/src/json-api/json-acl/10-atomic-operation.spec.ts)** – ACL enforcement across batch requests + +Each test file includes detailed JSDoc comments explaining the scenarios, ACL rules, and expected behavior. + +--- + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +--- + +## 📝 License + +This project is [MIT licensed](LICENSE). + +--- + +

Made with ❤️ by Aleksandr Kharkovey

diff --git a/apps/json-api-server-e2e/project.json b/apps/json-api-server-e2e/project.json index 07acaf38..329df61c 100644 --- a/apps/json-api-server-e2e/project.json +++ b/apps/json-api-server-e2e/project.json @@ -7,11 +7,7 @@ "projectType": "application", "targets": { "e2e": { - "dependsOn":[ { - "dependencies": true, - "target": "build", - "params": "ignore" - }], + "dependsOn": ["json-api-server:build", "json-api-nestjs-sdk:build"], "executor": "@nx/vite:test", "outputs": [ "{workspaceRoot}/coverage/{e2eProjectRoot}" @@ -23,11 +19,7 @@ } }, "e2e-micro": { - "dependsOn":[ { - "dependencies": true, - "target": "build", - "params": "ignore" - }], + "dependsOn": ["json-api-server:build", "json-api-nestjs-sdk:build"], "executor": "@nx/vite:test", "outputs": [ "{workspaceRoot}/coverage/{e2eProjectRoot}" diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/1-get-all-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/1-get-all-acl-check.spec.ts new file mode 100644 index 00000000..2b3a43eb --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/1-get-all-acl-check.spec.ts @@ -0,0 +1,155 @@ +/** + * ACL: GET All Resources - Permission and Field-Level Security + * + * This test suite verifies ACL (Access Control List) enforcement for fetching collections + * of resources. It tests three permission levels with different capabilities: + * + * 1. Admin Role: Full access without conditions + * - Can read all resources and all fields + * - Can include all relationships + * + * 2. Moderator Role: Full access with field restrictions + * - Can read all resources + * - Cannot read sensitive fields (salary, role in nested relations) + * - Field filtering applied automatically by ACL + * + * 3. User Role: Conditional access with field restrictions + * - Can read only public profiles OR their own profile + * - Cannot read sensitive fields (salary, role, isPublic, createdAt, updatedAt) + * - Can read own phone number but not others' phone numbers + * - Row-level filtering applied automatically by ACL + */ + +import { + ContextTestAcl, + UserRole, + UsersAcl, + UserProfileAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + +describe('ACL: GET All Resources (Collection Fetching)', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + }); + + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Admin Role: Full Access Without Restrictions', () => { + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Daphne user not found'); + contextTestAcl.context = { currentUser: adminUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder(CheckFieldAndInclude).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should fetch all profiles with all fields (no ACL restrictions)', async () => { + await jsonSdk.jonApiSdkService.getAll(UserProfileAcl) + }) + + it('should fetch all users with included profiles with all fields', async () => { + await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + }); + }) + + describe('Moderator Role: Full Access with Field-Level Restrictions', () => { + beforeEach(async () => { + const moderatorUser = usersAcl.find((user) => user.login === 'moderator'); + if (!moderatorUser) throw new Error('Sheila user not found'); + contextTestAcl.context = { currentUser: moderatorUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder(CheckFieldAndInclude).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should fetch all profiles but exclude sensitive fields (role, salary)', async () => { + const result = await jsonSdk.jonApiSdkService.getAll(UserProfileAcl) + + for (const item of result) { + expect(item.role).toBeUndefined() + expect(item.salary).toBeUndefined() + expect(item.role).toBeUndefined() + expect(item.firstName).toBeDefined() + expect(item.lastName).toBeDefined() + expect(item.avatar).toBeDefined() + expect(item.phone).toBeDefined() + expect(item.createdAt).toBeDefined() + expect(item.updatedAt).toBeDefined() + } + }) + + it('should fetch all users with profiles but exclude salary from nested profile', async () => { + const result = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + for (const item of result) { + expect(item.profile.salary).toBeUndefined() + expect(item.profile.role).toBeDefined() + expect(item.login).toBeDefined() + } + }); + }) + + describe('User Role: Conditional Row-Level Access with Field Restrictions', () => { + let countPublicProfile: UserProfileAcl[]; + beforeEach(async () => { + countPublicProfile = await jsonSdk.jonApiSdkService.getAll(UserProfileAcl, { + filter: { + target: { + isPublic: {eq: 'true'} + }, + } + }) + const bobUser = usersAcl.find((user) => user.login === 'bob'); + if (!bobUser) throw new Error('bob user not found'); + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should fetch only public profiles and own profile, with field restrictions and conditional phone visibility', async () => { + + const result = await jsonSdk.jonApiSdkService.getAll(UserProfileAcl); + expect(result.length).toBe(countPublicProfile.length + 1) + for (const item of result) { + expect(item.salary).toBeUndefined() + expect(item.isPublic).toBeUndefined() + expect(item.role).toBeUndefined() + expect(item.createdAt).toBeUndefined() + expect(item.updatedAt).toBeUndefined() + + if ((contextTestAcl.context.currentUser as UsersAcl).profile.id === item.id) { + expect(item.phone).toBeDefined() + } else { + expect(item.phone).toBeUndefined() + } + + expect(item.firstName).toBeDefined() + expect(item.lastName).toBeDefined() + expect(item.avatar).toBeDefined() + expect(item.bio).toBeDefined() + + } + }) + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/10-atomic-operation.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/10-atomic-operation.spec.ts new file mode 100644 index 00000000..a4308508 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/10-atomic-operation.spec.ts @@ -0,0 +1,143 @@ +/** + * ACL: Atomic Operations - ACL Enforcement Across Batch Requests + * + * This test suite verifies ACL enforcement for atomic operations (batch requests) + * where multiple operations are executed together. ACL rules are evaluated for + * EACH individual operation within the atomic request. + * + * 1. Admin Role: Full atomic operation access + * - Can execute multiple operations in one atomic request + * - Can POST, PATCH, and DELETE in a single transaction + * - All operations succeed when permissions allow + * + * 2. Moderator Role: Partial atomic operation access + * - Can POST and PATCH in atomic request + * - CANNOT DELETE (returns 403 Forbidden for entire atomic request) + * - Atomic request fails if ANY operation violates ACL + * - Error message indicates which operation failed (e.g., "deleteOne on ArticleAcl") + * - Atomicity ensures either ALL operations succeed or ALL fail + */ + +import { faker } from '@faker-js/faker'; +import { + ArticleAcl, + ArticleStatus, + ArticleVisibility, + ContextTestAcl, + UserRole, + UsersAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { AxiosError } from 'axios'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + +const getArticleData = () => ({ + title: faker.lorem.sentence(), + content: faker.lorem.paragraphs(8), + coAuthorIds: [], + status: ArticleStatus.PUBLISHED, + visibility: ArticleVisibility.PUBLIC, + metadata: { + readTime: faker.number.int({ min: 5, max: 30 }), + featured: true, + premium: false, + }, + publishedAt: faker.date.past(), + expiresAt: null, +}); + +describe('ACL: Atomic Operations (Batch Request ACL Enforcement)', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + let articleAcl: ArticleAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + articleAcl = await jsonSdk.jonApiSdkService.getAll(ArticleAcl, { + include: ['author', 'editor'], + }); + }); + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Admin Role: Full Atomic Operation Access', () => { + let bobUser: UsersAcl; + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Admin user not found'); + + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + contextTestAcl.context = { currentUser: adminUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should execute atomic operation with POST, PATCH, and DELETE all succeeding', async () => { + const articleForCreate = Object.assign( + new ArticleAcl(), + getArticleData(), + ); + articleForCreate.author = bobUser; + + const [articleForUpdate] = await jsonSdk.atomicFactory().postOne(articleForCreate).run(); + articleForUpdate.title = 'new title' + await jsonSdk.atomicFactory().patchOne(articleForUpdate).deleteOne(articleForUpdate).run(); + }); + }); + + describe('Moderator Role: Partial Atomic Operation Access with DELETE Restriction', () => { + + let bobUser: UsersAcl; + let moderatorUser: UsersAcl; + beforeEach(async () => { + const posibleModeratorUser = usersAcl.find( + (user) => user.login === 'moderator' + ); + if (!posibleModeratorUser) throw new Error('Sheila user not found'); + moderatorUser = posibleModeratorUser; + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + contextTestAcl.context = { currentUser: moderatorUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should return 403 Forbidden for entire atomic request when DELETE operation violates ACL (POST and PATCH allowed but DELETE forbidden)', async () => { + const articleForCreate = Object.assign( + new ArticleAcl(), + getArticleData() + ); + articleForCreate.author = moderatorUser; + articleForCreate.status = ArticleStatus.DRAFT; + try { + const [articleForUpdate] = await jsonSdk.atomicFactory().postOne(articleForCreate).run(); + articleForUpdate.status = ArticleStatus.REVIEW; + await jsonSdk.atomicFactory().patchOne(articleForUpdate).deleteOne(articleForUpdate).run(); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + expect(((e as AxiosError).response?.data as {error: string})['error']).toContain('deleteOne on ArticleAcl'); + } + }) + + }) +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/2-get-one-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/2-get-one-acl-check.spec.ts new file mode 100644 index 00000000..aabba4e5 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/2-get-one-acl-check.spec.ts @@ -0,0 +1,184 @@ +/** + * ACL: GET One Resource - Permission and Field-Level Security + * + * This test suite verifies ACL enforcement for fetching individual resources by ID. + * It tests three permission levels with different capabilities: + * + * 1. Admin Role: Full access without conditions + * - Can read any resource by ID with all fields + * - Can include all relationships + * + * 2. Moderator Role: Full access with field restrictions + * - Can read any resource by ID + * - Cannot read sensitive fields (salary, role in nested relations) + * - Field filtering applied automatically by ACL + * + * 3. User Role: Conditional row-level access with field restrictions + * - Can read own profile with phone number visible + * - Can read public profiles with phone number hidden + * - CANNOT read private profiles (returns 404 Not Found) + * - Cannot read sensitive fields (salary, role, isPublic, createdAt, updatedAt) + * - Row-level filtering prevents access to forbidden resources + */ + +import { + ContextTestAcl, + UserProfileAcl, + UserRole, + UsersAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { AxiosError } from 'axios'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + +describe('ACL: GET One Resource (Single Resource Fetching)', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + let publicUser: UsersAcl; + let notPublicUser: UsersAcl; + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + publicUser = usersAcl.find((i) => i.profile.isPublic) as UsersAcl; + notPublicUser = usersAcl.find( + (i) => !i.profile.isPublic && i.login !== 'bob' + ) as UsersAcl; + }); + + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Admin Role: Full Access Without Restrictions', () => { + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Daphne user not found'); + contextTestAcl.context = { currentUser: adminUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should fetch any user by ID with all fields (no ACL restrictions)', async () => { + await jsonSdk.jonApiSdkService.getOne(UsersAcl, usersAcl[0].id); + }); + + it('should fetch any user by ID with included profile with all fields', async () => { + await jsonSdk.jonApiSdkService.getOne(UsersAcl, usersAcl[0].id, { + include: ['profile'], + }); + }); + }); + + describe('Moderator Role: Full Access with Field-Level Restrictions', () => { + beforeEach(async () => { + const moderatorUser = usersAcl.find((user) => user.login === 'moderator'); + if (!moderatorUser) throw new Error('Sheila user not found'); + contextTestAcl.context = { currentUser: moderatorUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should fetch any profile by ID but exclude sensitive fields (role, salary)', async () => { + const item = await jsonSdk.jonApiSdkService.getOne( + UserProfileAcl, + usersAcl[0].id + ); + expect(item.role).toBeUndefined(); + expect(item.salary).toBeUndefined(); + expect(item.role).toBeUndefined(); + expect(item.firstName).toBeDefined(); + expect(item.lastName).toBeDefined(); + expect(item.avatar).toBeDefined(); + expect(item.phone).toBeDefined(); + expect(item.createdAt).toBeDefined(); + expect(item.updatedAt).toBeDefined(); + }); + + it('should fetch any user by ID with profile but exclude salary from nested profile', async () => { + const item = await jsonSdk.jonApiSdkService.getOne( + UsersAcl, + usersAcl[0].id, + { + include: ['profile'], + } + ); + expect(item.profile.salary).toBeUndefined(); + expect(item.profile.role).toBeDefined(); + expect(item.login).toBeDefined(); + }); + }); + + describe('User Role: Conditional Row-Level Access with Field Restrictions', () => { + let bobUser: UsersAcl; + beforeEach(async () => { + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('bob user not found'); + bobUser = posibleBobUser; + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should fetch own profile with phone visible and sensitive fields excluded', async () => { + const item = await jsonSdk.jonApiSdkService.getOne( + UserProfileAcl, + bobUser.profile.id + ); + expect(item.salary).toBeUndefined(); + expect(item.isPublic).toBeUndefined(); + expect(item.role).toBeUndefined(); + expect(item.createdAt).toBeUndefined(); + expect(item.updatedAt).toBeUndefined(); + expect(item.phone).toBeDefined(); + expect(item.firstName).toBeDefined(); + expect(item.lastName).toBeDefined(); + expect(item.avatar).toBeDefined(); + expect(item.bio).toBeDefined(); + }); + + it('should fetch public profile with phone hidden and sensitive fields excluded', async () => { + const item = await jsonSdk.jonApiSdkService.getOne( + UserProfileAcl, + publicUser.profile.id + ); + expect(item.salary).toBeUndefined(); + expect(item.isPublic).toBeUndefined(); + expect(item.role).toBeUndefined(); + expect(item.createdAt).toBeUndefined(); + expect(item.updatedAt).toBeUndefined(); + expect(item.phone).toBeUndefined(); + expect(item.firstName).toBeDefined(); + expect(item.lastName).toBeDefined(); + expect(item.avatar).toBeDefined(); + expect(item.bio).toBeDefined(); + }); + it('should return 404 Not Found when attempting to fetch private profile of another user', async () => { + try { + await jsonSdk.jonApiSdkService.getOne( + UserProfileAcl, + notPublicUser.profile.id + ); + assert.fail('should be not found'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(404); + } + }); + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/3-get-relationship-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/3-get-relationship-acl-check.spec.ts new file mode 100644 index 00000000..26e8b647 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/3-get-relationship-acl-check.spec.ts @@ -0,0 +1,156 @@ +/** + * ACL: GET Relationships - Relationship Access Control + * + * This test suite verifies ACL enforcement for fetching relationship data via + * the relationships endpoint (/resources/{id}/relationships/{relationshipName}). + * It tests three permission levels with different capabilities: + * + * 1. Admin Role: Full relationship access without conditions + * - Can fetch any relationship for any resource + * - Can access both 'profile' and 'posts' relationships + * + * 2. Moderator Role: Selective relationship access + * - CANNOT fetch 'profile' relationship (returns 403 Forbidden) + * - CAN fetch 'posts' relationship + * - Relationship-level restrictions applied + * + * 3. User Role: Owner-only relationship access + * - Can fetch relationships ONLY for own resources + * - CANNOT fetch relationships for other users' resources (returns 403 Forbidden) + * - Row-level security enforced at relationship endpoint level + */ + +import { + ContextTestAcl, + UserRole, + UsersAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { AxiosError } from 'axios'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + +describe('ACL: GET Relationships (Relationship Endpoint Access)', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + }); + + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Admin Role: Full Relationship Access Without Restrictions', () => { + let userForGet: UsersAcl; + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Daphne user not found'); + contextTestAcl.context = { currentUser: adminUser }; + + const posibleUserForGet = usersAcl.at(1); + if (!posibleUserForGet) throw new Error('First user not found'); + userForGet = posibleUserForGet; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should fetch profile relationship for any user', async () => { + await jsonSdk.jonApiSdkService.getRelationships(userForGet, 'profile'); + }); + + it('should fetch posts relationship for any user', async () => { + await jsonSdk.jonApiSdkService.getRelationships(userForGet, 'posts'); + }); + }); + + describe('Moderator Role: Selective Relationship Access', () => { + let userForGet: UsersAcl; + beforeEach(async () => { + const moderatorUser = usersAcl.find((user) => user.login === 'moderator'); + if (!moderatorUser) throw new Error('Sheila user not found'); + contextTestAcl.context = { currentUser: moderatorUser }; + + const posibleUserForGet = usersAcl.at(1); + if (!posibleUserForGet) throw new Error('First user not found'); + userForGet = posibleUserForGet; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should return 403 Forbidden when fetching profile relationship (restricted relationship)', async () => { + try { + await jsonSdk.jonApiSdkService.getRelationships(userForGet, 'profile'); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + + it('should fetch posts relationship for any user (allowed relationship)', async () => { + await jsonSdk.jonApiSdkService.getRelationships(userForGet, 'posts'); + }); + }); + + describe('User Role: Owner-Only Relationship Access', () => { + let bobUser: UsersAcl; + let alisUser: UsersAcl; + beforeEach(async () => { + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('bob user not found'); + bobUser = posibleBobUser; + + const posibleAliseUser = usersAcl.find((user) => user.login === 'alice'); + if (!posibleAliseUser) throw new Error('alise user not found'); + alisUser = posibleAliseUser; + + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should fetch posts relationship for own user (bob accessing bob)', async () => { + await jsonSdk.jonApiSdkService.getRelationships(bobUser, 'posts'); + }); + it('should fetch profile relationship for own user (bob accessing bob)', async () => { + await jsonSdk.jonApiSdkService.getRelationships(bobUser, 'profile'); + }); + + it('should return 403 Forbidden when fetching profile relationship for another user (bob accessing alice)', async () => { + try { + await jsonSdk.jonApiSdkService.getRelationships(alisUser, 'profile'); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + + it('should return 403 Forbidden when fetching posts relationship for another user (bob accessing alice)', async () => { + try { + await jsonSdk.jonApiSdkService.getRelationships(alisUser, 'posts'); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/4-patch-one-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/4-patch-one-acl-check.spec.ts new file mode 100644 index 00000000..8a6c5b3c --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/4-patch-one-acl-check.spec.ts @@ -0,0 +1,264 @@ +/** + * ACL: PATCH One Resource - Update Permission and Field-Level Security + * + * This test suite verifies ACL enforcement for updating resources. It tests complex + * permission scenarios including field-level restrictions and conditional value validation. + * + * 1. Admin Role: Full update access without conditions + * - Can update any resource with any field values + * + * 2. Moderator Role: Complex field and value restrictions + * - CANNOT update published articles (status='published') - returns 403 Forbidden + * - CANNOT update 'title' field in non-published articles - returns 403 Forbidden + * - CANNOT set status to 'published' - returns 403 Forbidden + * - CAN set status to 'review' for non-published articles + * - Field-level and value-level ACL enforced + * + * 3. User Role: Owner-based conditional update access + * a) coAuthor scenario: + * - CANNOT add new coAuthorIds - returns 403 Forbidden + * - CANNOT modify coAuthorIds while keeping themselves - returns 403 Forbidden + * - CAN remove themselves from coAuthorIds + * b) Author scenario: + * - CAN update own articles + * - Row-level security enforced (only own resources) + */ + +import { + ArticleAcl, + ArticleStatus, + ContextTestAcl, + UserRole, + UsersAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { AxiosError } from 'axios'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + +describe('ACL: PATCH One Resource (Update Operations)', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + let articleAcl: ArticleAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + articleAcl = await jsonSdk.jonApiSdkService.getAll(ArticleAcl, { + include: ['author', 'editor'], + }); + }); + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Admin Role: Full Update Access Without Restrictions', () => { + let articleForUpdate: ArticleAcl; + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Daphne user not found'); + + const posibleArticle = articleAcl.find( + (i) => i.author.login !== 'bob' && i.author.login !== 'alice' + ); + if (!posibleArticle) throw new Error('article not found'); + articleForUpdate = posibleArticle; + + contextTestAcl.context = { currentUser: adminUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should update any article with any field values (no ACL restrictions)', async () => { + articleForUpdate.title = 'new title'; + await jsonSdk.jonApiSdkService.patchOne(articleForUpdate); + }); + }); + + describe('Moderator Role: Field and Value-Level Restrictions', () => { + let articlePublishForUpdate: ArticleAcl; + let articleNoPublishForUpdate: ArticleAcl; + beforeEach(async () => { + const moderatorUser = usersAcl.find((user) => user.login === 'moderator'); + + const posiblePublishArticle = articleAcl.find( + (item) => item.status === 'published' + ); + if (!posiblePublishArticle) throw new Error('article not found'); + articlePublishForUpdate = posiblePublishArticle; + + const posibleNpPublishArticle = articleAcl.find( + (item) => item.status !== 'published' && item.status !== 'review' + ); + if (!posibleNpPublishArticle) throw new Error('article not found'); + articleNoPublishForUpdate = posibleNpPublishArticle; + + if (!moderatorUser) throw new Error('Sheila user not found'); + contextTestAcl.context = { currentUser: moderatorUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should return 403 Forbidden when attempting to update published article', async () => { + try { + await jsonSdk.jonApiSdkService.patchOne(articlePublishForUpdate); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + + it('should return 403 Forbidden when attempting to update restricted field (title) in non-published article', async () => { + const oldTitle = articleNoPublishForUpdate.title; + try { + articleNoPublishForUpdate.title = 'new title'; + await jsonSdk.jonApiSdkService.patchOne(articleNoPublishForUpdate); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + articleNoPublishForUpdate.title = oldTitle; + } + }); + + it('should return 403 Forbidden when attempting to set status to forbidden value (published)', async () => { + try { + articleNoPublishForUpdate.status = ArticleStatus.PUBLISHED; + await jsonSdk.jonApiSdkService.patchOne(articleNoPublishForUpdate); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + + it('should update non-published article with allowed field (status) and allowed value (review)', async () => { + articleNoPublishForUpdate.status = ArticleStatus.REVIEW; + // @ts-ignore + delete articleNoPublishForUpdate.author; + await jsonSdk.jonApiSdkService.patchOne(articleNoPublishForUpdate); + }); + }); + + describe('User Role: Owner-Based Conditional Update Access', () => { + let aliceUser: UsersAcl; + let bobUser: UsersAcl; + let articleForUpdate: ArticleAcl; + let articleForUpdateAlice: ArticleAcl; + beforeEach(async () => { + const posibleAliceUser = usersAcl.find((user) => user.login === 'alice'); + if (!posibleAliceUser) throw new Error('bob user not found'); + aliceUser = posibleAliceUser; + + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + const listAliceArticleForUpdate = await jsonSdk.jonApiSdkService.getAll( + ArticleAcl, + { + filter: { + author: { id: { eq: aliceUser.id.toString() } }, + }, + include: ['author'], + } + ); + + const posibleAliceArticle = listAliceArticleForUpdate.at(0); + if (!posibleAliceArticle) throw new Error('article not found'); + articleForUpdateAlice = posibleAliceArticle; + }); + describe('coAuthor Scenario: Can Only Remove Self from coAuthorIds', () => { + beforeEach(async () => { + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + + const listArticleForUpdate = await jsonSdk.jonApiSdkService.getAll( + ArticleAcl, + { + filter: { + author: { id: { eq: aliceUser.id.toString() } }, + target: { coAuthorIds: { some: [bobUser.id.toString()] } }, + }, + include: ['author'], + } + ); + + const posibleArticle = listArticleForUpdate.at(0); + if (!posibleArticle) throw new Error('article not found'); + articleForUpdate = posibleArticle; + }); + + it('should return 403 Forbidden when adding new coAuthorIds while keeping self', async () => { + const save = articleForUpdate.coAuthorIds; + try { + articleForUpdate.coAuthorIds = [...articleForUpdate.coAuthorIds, 6]; + // @ts-ignore + delete articleForUpdate.author; + await jsonSdk.jonApiSdkService.patchOne(articleForUpdate); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + articleForUpdate.coAuthorIds = save; + } + }); + it('should return 403 Forbidden when modifying coAuthorIds with new ids even after removing self', async () => { + const save = articleForUpdate.coAuthorIds; + try { + articleForUpdate.coAuthorIds = [ + ...articleForUpdate.coAuthorIds.filter((i) => i !== bobUser.id), + 6, + ]; + // @ts-ignore + delete articleForUpdate.author; + await jsonSdk.jonApiSdkService.patchOne(articleForUpdate); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + articleForUpdate.coAuthorIds = save; + } + }); + + it('should update article when coAuthor removes only themselves from coAuthorIds', async () => { + articleForUpdate.coAuthorIds = articleForUpdate.coAuthorIds.filter( + (i) => i !== bobUser.id + ); + // @ts-ignore + delete articleForUpdate.author; + await jsonSdk.jonApiSdkService.patchOne(articleForUpdate); + }); + }); + + describe('Author Scenario: Can Update Own Articles', () => { + beforeEach(async () => { + contextTestAcl.context = { currentUser: aliceUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + it('should update own article (alice updating alice article)', async () => { + articleForUpdateAlice.title = 'new Title'; + await jsonSdk.jonApiSdkService.patchOne(articleForUpdateAlice); + }); + }); + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/5-post-one-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/5-post-one-acl-check.spec.ts new file mode 100644 index 00000000..9e995acb --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/5-post-one-acl-check.spec.ts @@ -0,0 +1,209 @@ +/** + * ACL: POST One Resource - Create Permission and Field-Level Security + * + * This test suite verifies ACL enforcement for creating new resources. It tests + * complex permission scenarios including author assignment and status restrictions. + * + * 1. Admin Role: Full create access without conditions + * - Can create articles with any author (including other users) + * - Can set any status value + * + * 2. Moderator Role: Self-author-only restriction + * - CAN create articles with self as author + * - CANNOT create articles with other users as author - returns 403 Forbidden + * - Conditional author validation enforced + * + * 3. User Role: Self-author and status restrictions + * - CAN create articles with self as author AND status='draft' + * - CANNOT create articles with status='published' - returns 403 Forbidden + * - CANNOT create articles with other users as author - returns 403 Forbidden + * - Combined field and conditional ACL enforced + */ + +import { + ArticleAcl, + ArticleStatus, + ArticleVisibility, + ContextTestAcl, + UserRole, + UsersAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { faker } from '@faker-js/faker'; +import { AxiosError } from 'axios'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + +const getArticleData = () => ({ + title: faker.lorem.sentence(), + content: faker.lorem.paragraphs(8), + coAuthorIds: [], + status: ArticleStatus.PUBLISHED, + visibility: ArticleVisibility.PUBLIC, + metadata: { + readTime: faker.number.int({ min: 5, max: 30 }), + featured: true, + premium: false, + }, + publishedAt: faker.date.past(), + expiresAt: null, +}); + +describe('ACL: POST One Resource (Create Operations)', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + let articleAcl: ArticleAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + articleAcl = await jsonSdk.jonApiSdkService.getAll(ArticleAcl, { + include: ['author', 'editor'], + }); + }); + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Admin Role: Full Create Access Without Restrictions', () => { + let bobUser: UsersAcl; + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Admin user not found'); + + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + contextTestAcl.context = { currentUser: adminUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should create article with any author (bob as author while admin is creating)', async () => { + const articleForCreate = Object.assign( + new ArticleAcl(), + getArticleData() + ); + articleForCreate.author = bobUser; + await jsonSdk.jonApiSdkService.postOne(articleForCreate); + }); + }); + + describe('Moderator Role: Self-Author-Only Restriction', () => { + let bobUser: UsersAcl; + let moderatorUser: UsersAcl; + beforeEach(async () => { + const posibleModeratorUser = usersAcl.find( + (user) => user.login === 'moderator' + ); + if (!posibleModeratorUser) throw new Error('Sheila user not found'); + moderatorUser = posibleModeratorUser; + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + contextTestAcl.context = { currentUser: moderatorUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should create article with self as author (moderator creating with moderator as author)', async () => { + const articleForCreate = Object.assign( + new ArticleAcl(), + getArticleData() + ); + articleForCreate.author = moderatorUser; + await jsonSdk.jonApiSdkService.postOne(articleForCreate); + }); + + it('should return 403 Forbidden when creating article with other user as author (moderator trying to set bob as author)', async () => { + try { + const articleForCreate = Object.assign( + new ArticleAcl(), + getArticleData() + ); + articleForCreate.author = bobUser; + await jsonSdk.jonApiSdkService.postOne(articleForCreate); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + }); + + describe('User Role: Self-Author and Status Restrictions', () => { + let aliceUser: UsersAcl; + let bobUser: UsersAcl; + beforeEach(async () => { + const posibleAliceUser = usersAcl.find( + (user) => user.login === 'alice' + ); + if (!posibleAliceUser) throw new Error('bob user not found'); + aliceUser = posibleAliceUser; + + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should create draft article with self as author (bob creating draft with bob as author)', async () => { + const articleForCreate = Object.assign( + new ArticleAcl(), + getArticleData() + ); + articleForCreate.author = bobUser; + articleForCreate.status = ArticleStatus.DRAFT; + await jsonSdk.jonApiSdkService.postOne(articleForCreate); + }); + + it('should return 403 Forbidden when creating published article (bob trying to create published article)', async () => { + try { + const articleForCreate = Object.assign( + new ArticleAcl(), + getArticleData() + ); + articleForCreate.author = bobUser; + await jsonSdk.jonApiSdkService.postOne(articleForCreate); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + + it('should return 403 Forbidden when creating article with other user as author (bob trying to set alice as author)', async () => { + try { + const articleForCreate = Object.assign( + new ArticleAcl(), + getArticleData() + ); + articleForCreate.author = aliceUser; + articleForCreate.status = ArticleStatus.DRAFT; + await jsonSdk.jonApiSdkService.postOne(articleForCreate); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/6-delete-one-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/6-delete-one-acl-check.spec.ts new file mode 100644 index 00000000..8d0a2c12 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/6-delete-one-acl-check.spec.ts @@ -0,0 +1,169 @@ +/** + * ACL: DELETE One Resource - Delete Permission with Conditional Restrictions + * + * This test suite verifies ACL enforcement for deleting resources. It tests + * complex permission scenarios based on article status and authorship. + * + * 1. Admin Role: Full delete access without conditions + * - Can delete any article regardless of status or author + * + * 2. Moderator Role: Can delete published articles + * - CAN delete published articles (even if not the author) + * - Status-based delete permission + * + * 3. User Role: Conditional delete based on status + * a) Author of published article: + * - CANNOT delete own published article - returns 403 Forbidden + * - Once published, article is protected from author deletion + * b) Author of non-published article: + * - CAN delete own non-published article (draft, review, etc.) + * - Owner-based delete permission for unpublished content + */ + +import { + ContextTestAcl, + UserRole, + UsersAcl, + ArticleAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { AxiosError } from 'axios'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + +describe('ACL: DELETE One Resource (Delete Operations)', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + let articleAcl: ArticleAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile'], + }); + articleAcl = await jsonSdk.jonApiSdkService.getAll(ArticleAcl, { + include: ['author', 'editor'], + }); + }); + + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Admin Role: Full Delete Access Without Restrictions', () => { + let articleForDelete: ArticleAcl; + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Daphne user not found'); + + const posibleArticle = articleAcl.find( + (i) => i.author.login !== 'bob' && i.author.login !== 'alice' + ); + if (!posibleArticle) throw new Error('article not found'); + articleForDelete = posibleArticle; + + contextTestAcl.context = { currentUser: adminUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should delete any article regardless of status or author (no ACL restrictions)', async () => { + await jsonSdk.jonApiSdkService.deleteOne(articleForDelete); + }); + }); + + describe('Moderator Role: Can Delete Published Articles', () => { + let articleForDelete: ArticleAcl; + beforeEach(async () => { + const moderatorUser = usersAcl.find((user) => user.login === 'moderator'); + + const posiblePosible = articleAcl.find( + (item) => item.author.login === 'alice' && item.status === 'published' + ); + if (!posiblePosible) throw new Error('article not found'); + articleForDelete = posiblePosible; + + if (!moderatorUser) throw new Error('Sheila user not found'); + contextTestAcl.context = { currentUser: moderatorUser }; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should delete published article (moderator deleting alice published article)', async () => { + await jsonSdk.jonApiSdkService.deleteOne(articleForDelete); + }); + }); + + describe('User Role: Conditional Delete Based on Article Status', () => { + describe('Author of Published Article: CANNOT Delete', () => { + let aliceUser: UsersAcl; + let articleAclAlice: ArticleAcl; + beforeEach(async () => { + const posibleAliceUser = usersAcl.find( + (user) => user.login === 'alice' + ); + if (!posibleAliceUser) throw new Error('bob user not found'); + aliceUser = posibleAliceUser; + + const posiblePosible = articleAcl.find( + (item) => + item.author.id === aliceUser.id && item.status === 'published' + ); + if (!posiblePosible) throw new Error('article not found'); + articleAclAlice = posiblePosible; + + contextTestAcl.context = { currentUser: aliceUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should return 403 Forbidden when author attempts to delete own published article', async () => { + try { + await jsonSdk.jonApiSdkService.deleteOne(articleAclAlice); + assert.fail('should be error'); + } catch (e) { + expect(e).toBeInstanceOf(AxiosError); + expect((e as AxiosError).response?.status).toBe(403); + } + }); + }); + describe('Author of Non-Published Article: CAN Delete', () => { + let bobUser: UsersAcl; + + let articleAclBobe: ArticleAcl; + beforeEach(async () => { + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('bob user not found'); + bobUser = posibleBobUser; + + const posibleArticleBob = articleAcl.find( + (item) => item.author.id === bobUser.id && item.status !== 'published' + ); + if (!posibleArticleBob) throw new Error('article not found'); + articleAclBobe = posibleArticleBob; + + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('should delete own non-published article (bob deleting bob draft article)', async () => { + await jsonSdk.jonApiSdkService.deleteOne(articleAclBobe); + }); + }); + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/7-patch-relationship-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/7-patch-relationship-acl-check.spec.ts new file mode 100644 index 00000000..f2464d91 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/7-patch-relationship-acl-check.spec.ts @@ -0,0 +1,217 @@ +import { + CommentAcl, + ContextTestAcl, + PostAcl, + UserRole, + UsersAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { AxiosError } from 'axios'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + + +describe('ACL patchRelationship:', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + let allPosts: PostAcl[]; + let allComments: CommentAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile', 'posts', 'aclComments'], + }); + allPosts = await jsonSdk.jonApiSdkService.getAll(PostAcl); + allComments = await jsonSdk.jonApiSdkService.getAll(CommentAcl); + }); + + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Without conditional: admin', () => { + let userForPatch: UsersAcl; + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Admin user not found'); + contextTestAcl.context = { currentUser: adminUser }; + + const posibleUserForPatch = usersAcl.at(1); + if (!posibleUserForPatch) throw new Error('User not found'); + userForPatch = posibleUserForPatch; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('patch rel aclComments for user (replace with different comments)', async () => { + // const newComments = allComments + // .filter((c) => !userForPatch.aclComments?.some((uc) => uc.id === c.id)) + // .slice(0, 2); + // if (newComments.length < 2) { + // throw new Error('Not enough comments available'); + // } + // userForPatch.aclComments = newComments as any; + // await jsonSdk.jonApiSdkService.patchRelationships( + // userForPatch, + // 'aclComments' + // ); + }); + + it('patch rel posts for user (replace with different posts)', async () => { + // const newPosts = allPosts + // .filter((p) => !userForPatch.posts?.some((up) => up.id === p.id)) + // .slice(0, 2); + // if (newPosts.length < 2) { + // throw new Error('Not enough posts available'); + // } + // userForPatch.posts = newPosts as any; + // await jsonSdk.jonApiSdkService.patchRelationships(userForPatch, 'posts'); + }); + }); + + describe('Without conditional but with fields: moderator', () => { + let userForPatch: UsersAcl; + beforeEach(async () => { + const moderatorUser = usersAcl.find((user) => user.login === 'moderator'); + if (!moderatorUser) throw new Error('Moderator user not found'); + contextTestAcl.context = { currentUser: moderatorUser }; + + const posibleUserForPatch = usersAcl.at(1); + if (!posibleUserForPatch) throw new Error('User not found'); + userForPatch = posibleUserForPatch; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('patch rel aclComments for user, should be error', async () => { + // try { + // const newComments = allComments + // .filter((c) => !userForPatch.aclComments?.some((uc) => uc.id === c.id)) + // .slice(0, 2); + // if (newComments.length < 2) { + // throw new Error('Not enough comments available'); + // } + // userForPatch.aclComments = newComments as any; + // await jsonSdk.jonApiSdkService.patchRelationships( + // userForPatch, + // 'aclComments' + // ); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('patch rel posts for user (replace with different posts)', async () => { + // const newPosts = allPosts + // .filter((p) => !userForPatch.posts?.some((up) => up.id === p.id)) + // .slice(0, 2); + // if (newPosts.length < 2) { + // throw new Error('Not enough posts available'); + // } + // userForPatch.posts = newPosts as any; + // await jsonSdk.jonApiSdkService.patchRelationships(userForPatch, 'posts'); + }); + }); + + describe('With conditional: user', () => { + let bobUser: UsersAcl; + let aliceUser: UsersAcl; + beforeEach(async () => { + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + const posibleAliceUser = usersAcl.find((user) => user.login === 'alice'); + if (!posibleAliceUser) throw new Error('Alice user not found'); + aliceUser = posibleAliceUser; + + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('patch rel aclComments for bob user (replace with different comments)', async () => { + // const newComments = allComments + // .filter((c) => !bobUser.aclComments?.some((uc) => uc.id === c.id)) + // .slice(0, 2); + // if (newComments.length < 2) { + // throw new Error('Not enough comments available'); + // } + // bobUser.aclComments = newComments as any; + // await jsonSdk.jonApiSdkService.patchRelationships( + // bobUser, + // 'aclComments' + // ); + }); + + it('patch rel posts for bob user, should be error', async () => { + // try { + // const newPosts = allPosts + // .filter((p) => !bobUser.posts?.some((up) => up.id === p.id)) + // .slice(0, 2); + // if (newPosts.length < 2) { + // throw new Error('Not enough posts available'); + // } + // bobUser.posts = newPosts as any; + // await jsonSdk.jonApiSdkService.patchRelationships(bobUser, 'posts'); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('patch rel aclComments for alice, should be error', async () => { + // try { + // const newComments = allComments + // .filter((c) => !aliceUser.aclComments?.some((uc) => uc.id === c.id)) + // .slice(0, 2); + // if (newComments.length < 2) { + // throw new Error('Not enough comments available'); + // } + // aliceUser.aclComments = newComments as any; + // await jsonSdk.jonApiSdkService.patchRelationships( + // aliceUser, + // 'aclComments' + // ); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('patch rel posts for alice, should be error', async () => { + // try { + // const newPosts = allPosts + // .filter((p) => !aliceUser.posts?.some((up) => up.id === p.id)) + // .slice(0, 2); + // if (newPosts.length < 2) { + // throw new Error('Not enough posts available'); + // } + // aliceUser.posts = newPosts as any; + // await jsonSdk.jonApiSdkService.patchRelationships(aliceUser, 'posts'); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/8-post-relationship-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/8-post-relationship-acl-check.spec.ts new file mode 100644 index 00000000..ee1dfd5d --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/8-post-relationship-acl-check.spec.ts @@ -0,0 +1,218 @@ +import { + CommentAcl, + ContextTestAcl, + PostAcl, + UserRole, + UsersAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { AxiosError } from 'axios'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + + + +describe('ACL postRelationship:', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + let allPosts: PostAcl[]; + let allComments: CommentAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile', 'posts', 'aclComments'], + }); + allPosts = await jsonSdk.jonApiSdkService.getAll(PostAcl); + allComments = await jsonSdk.jonApiSdkService.getAll(CommentAcl); + }); + + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Without conditional: admin', () => { + let userForAdd: UsersAcl; + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Admin user not found'); + contextTestAcl.context = { currentUser: adminUser }; + + const posibleUserForAdd = usersAcl.at(1); + if (!posibleUserForAdd) throw new Error('User not found'); + userForAdd = posibleUserForAdd; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('add rel aclComments for user', async () => { + // const commentToAdd = allComments.find( + // (c) => !userForAdd.aclComments?.some((uc) => uc.id === c.id) + // ); + // if (!commentToAdd) { + // throw new Error('No available comment to add'); + // } + // userForAdd.aclComments = [commentToAdd] as any; + // await jsonSdk.jonApiSdkService.postRelationships( + // userForAdd, + // 'aclComments' + // ); + }); + + it('add rel posts for user', async () => { + // const postToAdd = allPosts.find( + // (p) => !userForAdd.posts?.some((up) => up.id === p.id) + // ); + // if (!postToAdd) { + // throw new Error('No available post to add'); + // } + // userForAdd.posts = [postToAdd] as any; + // await jsonSdk.jonApiSdkService.postRelationships(userForAdd, 'posts'); + }); + }); + + describe('Without conditional but with fields: moderator', () => { + let userForAdd: UsersAcl; + beforeEach(async () => { + const moderatorUser = usersAcl.find((user) => user.login === 'moderator'); + if (!moderatorUser) throw new Error('Moderator user not found'); + contextTestAcl.context = { currentUser: moderatorUser }; + + const posibleUserForAdd = usersAcl.at(1); + if (!posibleUserForAdd) throw new Error('User not found'); + userForAdd = posibleUserForAdd; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('add rel aclComments for user, should be error', async () => { + // try { + // const commentToAdd = allComments.find( + // (c) => !userForAdd.aclComments?.some((uc) => uc.id === c.id) + // ); + // if (!commentToAdd) { + // throw new Error('No available comment to add'); + // } + // userForAdd.aclComments = [commentToAdd] as any; + // await jsonSdk.jonApiSdkService.postRelationships( + // userForAdd, + // 'aclComments' + // ); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('add rel posts for user', async () => { + // const postToAdd = allPosts.find( + // (p) => !userForAdd.posts?.some((up) => up.id === p.id) + // ); + // if (!postToAdd) { + // throw new Error('No available post to add'); + // } + // userForAdd.posts = [postToAdd] as any; + // await jsonSdk.jonApiSdkService.postRelationships(userForAdd, 'posts'); + }); + }); + + describe('With conditional: user', () => { + let bobUser: UsersAcl; + let aliceUser: UsersAcl; + beforeEach(async () => { + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + const posibleAliceUser = usersAcl.find((user) => user.login === 'alice'); + if (!posibleAliceUser) throw new Error('Alice user not found'); + aliceUser = posibleAliceUser; + + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('add rel aclComments for bob user', async () => { + // const commentToAdd = allComments.find( + // (c) => !bobUser.aclComments?.some((uc) => uc.id === c.id) + // ); + // if (!commentToAdd) { + // throw new Error('No available comment to add'); + // } + // bobUser.aclComments = [commentToAdd] as any; + // await jsonSdk.jonApiSdkService.postRelationships( + // bobUser, + // 'aclComments' + // ); + }); + + it('add rel posts for bob user, should be error', async () => { + // try { + // const postToAdd = allPosts.find( + // (p) => !bobUser.posts?.some((up) => up.id === p.id) + // ); + // if (!postToAdd) { + // throw new Error('No available post to add'); + // } + // bobUser.posts = [postToAdd] as any; + // await jsonSdk.jonApiSdkService.postRelationships(bobUser, 'posts'); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('add rel aclComments for alice, should be error', async () => { + // try { + // const commentToAdd = allComments.find( + // (c) => !aliceUser.aclComments?.some((uc) => uc.id === c.id) + // ); + // if (!commentToAdd) { + // throw new Error('No available comment to add'); + // } + // aliceUser.aclComments = [commentToAdd] as any; + // await jsonSdk.jonApiSdkService.postRelationships( + // aliceUser, + // 'aclComments' + // ); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('add rel posts for alice, should be error', async () => { + // try { + // const postToAdd = allPosts.find( + // (p) => !aliceUser.posts?.some((up) => up.id === p.id) + // ); + // if (!postToAdd) { + // throw new Error('No available post to add'); + // } + // aliceUser.posts = [postToAdd] as any; + // await jsonSdk.jonApiSdkService.postRelationships(aliceUser, 'posts'); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-acl/9-delete-relationship-acl-check.spec.ts b/apps/json-api-server-e2e/src/json-api/json-acl/9-delete-relationship-acl-check.spec.ts new file mode 100644 index 00000000..320b9318 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/json-acl/9-delete-relationship-acl-check.spec.ts @@ -0,0 +1,205 @@ +import { + ContextTestAcl, + UserRole, + UsersAcl, +} from '@nestjs-json-api/microorm-database/entity'; +import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; +import { AxiosError } from 'axios'; + +import { creatSdk } from '../utils/run-application'; +import { AbilityBuilder, CheckFieldAndInclude } from '../utils/acl/acl'; + +describe('ACL deleteRelationship:', () => { + let contextTestAcl = new ContextTestAcl(); + let usersAcl: UsersAcl[]; + contextTestAcl.aclRules = { rules: [] }; + contextTestAcl.context = {}; + let jsonSdk: JsonSdkPromise; + + beforeEach(async () => { + jsonSdk = creatSdk(); + contextTestAcl = await jsonSdk.jonApiSdkService.postOne(contextTestAcl); + usersAcl = await jsonSdk.jonApiSdkService.getAll(UsersAcl, { + include: ['profile', 'posts', 'aclComments'], + }); + }); + + afterEach(async () => { + await jsonSdk.jonApiSdkService.deleteOne(contextTestAcl); + }); + + describe('Without conditional: admin', () => { + let userForDelete: UsersAcl; + beforeEach(async () => { + const adminUser = usersAcl.find((user) => user.login === 'admin'); + if (!adminUser) throw new Error('Admin user not found'); + contextTestAcl.context = { currentUser: adminUser }; + + const posibleUserForDelete = usersAcl.find( + (u) => u.aclComments && u.aclComments.length > 0 + ); + if (!posibleUserForDelete) + throw new Error('User with comments not found'); + userForDelete = posibleUserForDelete; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.admin).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('delete rel aclComments for user', async () => { + // const commentToDelete = userForDelete.aclComments[0]; + // if (!commentToDelete) { + // throw new Error('User has no cpmment'); + // } + // userForDelete.aclComments = [commentToDelete] as any + // await jsonSdk.jonApiSdkService.deleteRelationships( + // userForDelete, + // 'aclComments' + // ); + }); + + it('delete rel posts for user', async () => { + // const postToDelete = userForDelete.posts[0]; + // if (!postToDelete) { + // throw new Error('User has no posts'); + // } + // userForDelete.posts = [postToDelete] as any + // await jsonSdk.jonApiSdkService.deleteRelationships( + // userForDelete, + // 'posts' + // ); + }); + }); + + describe('Without conditional but with fields: moderator', () => { + let userForDelete: UsersAcl; + beforeEach(async () => { + const moderatorUser = usersAcl.find((user) => user.login === 'moderator'); + if (!moderatorUser) throw new Error('Moderator user not found'); + contextTestAcl.context = { currentUser: moderatorUser }; + + const posibleUserForDelete = usersAcl.find( + (u) => u.posts && u.posts.length > 0 + ); + if (!posibleUserForDelete) throw new Error('User with posts not found'); + userForDelete = posibleUserForDelete; + + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.moderator).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('delete rel aclComments for user, should be error', async () => { + // try { + // const commentToDelete = userForDelete.aclComments![0]; + // await jsonSdk.jonApiSdkService.deleteRelationships( + // userForDelete, + // 'aclComments', + // commentToDelete + // ); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('delete rel posts for user', async () => { + // const postToDelete = userForDelete.posts![0]; + // await jsonSdk.jonApiSdkService.deleteRelationships( + // userForDelete, + // 'posts', + // postToDelete + // ); + }); + }); + + describe('With conditional: user', () => { + let bobUser: UsersAcl; + let aliceUser: UsersAcl; + beforeEach(async () => { + const posibleBobUser = usersAcl.find((user) => user.login === 'bob'); + if (!posibleBobUser) throw new Error('Bob user not found'); + bobUser = posibleBobUser; + + const posibleAliceUser = usersAcl.find((user) => user.login === 'alice'); + if (!posibleAliceUser) throw new Error('Alice user not found'); + aliceUser = posibleAliceUser; + + contextTestAcl.context = { currentUser: bobUser }; + contextTestAcl.aclRules.rules = new AbilityBuilder( + CheckFieldAndInclude + ).permissionsFor(UserRole.user).rules as any; + await jsonSdk.jonApiSdkService.patchOne(contextTestAcl); + }); + + it('delete rel aclComments for bob user', async () => { + // if (!bobUser.aclComments || bobUser.aclComments.length === 0) { + // throw new Error('Bob has no comments'); + // } + // const commentToDelete = bobUser.aclComments[0]; + // await jsonSdk.jonApiSdkService.deleteRelationships( + // bobUser, + // 'aclComments', + // commentToDelete + // ); + }); + + it('delete rel posts for bob user, should be error', async () => { + // try { + // if (!bobUser.posts || bobUser.posts.length === 0) { + // throw new Error('Bob has no posts'); + // } + // const postToDelete = bobUser.posts[0]; + // await jsonSdk.jonApiSdkService.deleteRelationships( + // bobUser, + // 'posts', + // postToDelete + // ); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('delete rel aclComments for alice, should be error', async () => { + // try { + // if (!aliceUser.aclComments || aliceUser.aclComments.length === 0) { + // throw new Error('Alice has no comments'); + // } + // const commentToDelete = aliceUser.aclComments[0]; + // await jsonSdk.jonApiSdkService.deleteRelationships( + // aliceUser, + // 'aclComments', + // commentToDelete + // ); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + + it('delete rel posts for alice, should be error', async () => { + // try { + // if (!aliceUser.posts || aliceUser.posts.length === 0) { + // throw new Error('Alice has no posts'); + // } + // const postToDelete = aliceUser.posts[0]; + // await jsonSdk.jonApiSdkService.deleteRelationships( + // aliceUser, + // 'posts', + // postToDelete + // ); + // assert.fail('should be error'); + // } catch (e) { + // expect(e).toBeInstanceOf(AxiosError); + // expect((e as AxiosError).response?.status).toBe(403); + // } + }); + }); +}); diff --git a/apps/json-api-server-e2e/src/json-api/json-api-sdk/atomic-sdk.spec.ts b/apps/json-api-server-e2e/src/json-api/json-api-sdk/atomic-sdk.spec.ts index b89a48c5..97a30bb3 100644 --- a/apps/json-api-server-e2e/src/json-api/json-api-sdk/atomic-sdk.spec.ts +++ b/apps/json-api-server-e2e/src/json-api/json-api-sdk/atomic-sdk.spec.ts @@ -1,4 +1,18 @@ -import { INestApplication } from '@nestjs/common'; +/** + * JSON API: Atomic Operations - Batch Requests + * + * This test suite demonstrates how to use the JSON API SDK's atomic operations + * to execute multiple operations in a single request. Atomic operations ensure + * that all operations succeed or fail together. + * + * Examples include: + * - Executing multiple POST, PATCH, and relationship operations in one request + * - Using temporary IDs (lid) to reference resources created within the same request + * - Updating relationships (replacing and appending) atomically + * - Maintaining referential integrity across multiple operations + * - Handling complex resource graphs with dependencies + */ + import { FilterOperand, JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; import { Addresses, @@ -9,19 +23,9 @@ import { } from '@nestjs-json-api/typeorm-database'; import { faker } from '@faker-js/faker'; import { getUser } from '../utils/data-utils'; -import { run, creatSdk } from '../utils/run-application'; - -let app: INestApplication; - -beforeAll(async () => { - app = await run(); -}); +import { creatSdk } from '../utils/run-application'; -afterAll(async () => { - await app.close(); -}); - -describe('Atomic method:', () => { +describe('Atomic Operations (Batch Requests)', () => { let jsonSdk: JsonSdkPromise; let addressArray: Addresses[]; let rolesArray: Roles[]; @@ -102,7 +106,7 @@ describe('Atomic method:', () => { } }); - it('Try check intreceptor', async () => { + it('should execute a simple atomic operation with a single POST request', async () => { const newUser = getUser(); newUser.addresses = addressArray[0]; try { @@ -113,7 +117,7 @@ describe('Atomic method:', () => { } }); - it('Should be correct work', async () => { + it('should execute multiple operations atomically: POST, PATCH, patchRelationships, and postRelationships', async () => { const newUser = getUser(); newUser.addresses = addressArray[0]; const resultCreate = await jsonSdk.atomicFactory().postOne(newUser).run(); @@ -172,7 +176,7 @@ describe('Atomic method:', () => { usersId.push(resultCreate[0].id); }); - it('Should be correct work with tmp Id', async () => { + it('should create multiple related resources using temporary IDs (lid) to reference resources within the same atomic request', async () => { const address = new Addresses(); address.city = faker.string.alpha(50); @@ -189,14 +193,22 @@ describe('Atomic method:', () => { roles.name = faker.string.alpha(50); roles.key = faker.string.alpha(50); + const userAddress = new Addresses(); + + userAddress.city = faker.string.alpha(50); + userAddress.state = faker.string.alpha(50); + userAddress.country = faker.string.alpha(50); + userAddress.id = 10003; + const user = getUser(); - user.addresses = address; + user.addresses = userAddress; user.manager = manager; user.roles = [roles]; - const [addressPost, managerPost, rolesPost, userPost] = await jsonSdk + const [addressPost, userAddressPost, managerPost, rolesPost, userPost] = await jsonSdk .atomicFactory() .postOne(address) + .postOne(userAddress) .postOne(manager) .postOne(roles) .postOne(user) @@ -218,9 +230,9 @@ describe('Atomic method:', () => { expect(selectManager.addresses.id).toBe(addressPost.id); expect(selectUser.manager.id).toBe(managerPost.id); expect(selectUser.roles).toEqual([rolesPost]); - expect(selectUser.addresses).toEqual(addressPost); + expect(selectUser.addresses.id).toEqual(userAddressPost.id); - addressArray.push(addressPost); + addressArray.push(addressPost, userAddressPost); rolesArray.push(rolesPost); usersId.push(managerPost.id); usersId.push(userPost.id); diff --git a/apps/json-api-server-e2e/src/json-api/json-api-sdk/check-common-decorator.spec.ts b/apps/json-api-server-e2e/src/json-api/json-api-sdk/check-common-decorator.spec.ts index dd08d9c3..efaa2930 100644 --- a/apps/json-api-server-e2e/src/json-api/json-api-sdk/check-common-decorator.spec.ts +++ b/apps/json-api-server-e2e/src/json-api/json-api-sdk/check-common-decorator.spec.ts @@ -1,21 +1,24 @@ -import { INestApplication } from '@nestjs/common'; +/** + * JSON API: Common Decorators - Guards, Interceptors, and Filters + * + * This test suite demonstrates how NestJS common decorators work with the JSON API library. + * It verifies that standard NestJS decorators (Guards, Interceptors, Filters) can be applied + * at both controller and method levels to JSON API endpoints. + * + * Examples include: + * - Applying interceptors at controller and method levels + * - Using custom filters at controller and method levels + * - Protecting endpoints with guards at controller and method levels + * - Proper error handling and HTTP status codes + */ + import { FilterOperand, JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; import { AxiosError } from 'axios'; import { Users } from '@nestjs-json-api/typeorm-database'; -import { run, creatSdk } from '../utils/run-application'; - -let app: INestApplication; - -beforeAll(async () => { - app = await run(); -}); - -afterAll(async () => { - await app.close(); -}); +import { creatSdk } from '../utils/run-application'; -describe('Check common decorator', () => { +describe('NestJS Common Decorators Integration', () => { let jsonSdk: JsonSdkPromise; beforeEach(async () => { jsonSdk = creatSdk(); @@ -23,8 +26,8 @@ describe('Check common decorator', () => { afterEach(async () => {}); - describe('Check Interceptor', () => { - it('Should be call controller interceptor', async () => { + describe('Interceptors', () => { + it('should trigger controller-level interceptor and return validation error', async () => { expect.assertions(3); try { await jsonSdk.jonApiSdkService.getAll(Users, { @@ -47,7 +50,7 @@ describe('Check common decorator', () => { } }); - it('Should be call methode interceptor', async () => { + it('should trigger method-level interceptor and return validation error', async () => { expect.assertions(3); try { await jsonSdk.jonApiSdkService.getAll(Users, { @@ -71,8 +74,8 @@ describe('Check common decorator', () => { }); }); - describe('Check Filter', () => { - it('Should be able to filter controller by firstName', async () => { + describe('Exception Filters', () => { + it('should trigger controller-level exception filter and return custom HTTP status code', async () => { expect.assertions(4); try { await jsonSdk.jonApiSdkService.getAll(Users, { @@ -93,7 +96,7 @@ describe('Check common decorator', () => { expect(((e as AxiosError).response?.data as any)?.method).toBe(false); } }); - it('Should be able to filter testMethodFilter by firstName', async () => { + it('should trigger method-level exception filter and return custom HTTP status code', async () => { try { await jsonSdk.jonApiSdkService.getAll(Users, { filter: { @@ -116,8 +119,8 @@ describe('Check common decorator', () => { }); }); - describe('Check Guard', () => { - it('Should be be call controller guard', async () => { + describe('Guards', () => { + it('should trigger controller-level guard and deny access with 403 Forbidden', async () => { expect.assertions(3); try { await jsonSdk.jonApiSdkService.getAll(Users, { @@ -137,7 +140,7 @@ describe('Check common decorator', () => { ); } }); - it('Should be be call methode guard', async () => { + it('should trigger method-level guard and deny access with 403 Forbidden', async () => { expect.assertions(3); try { await jsonSdk.jonApiSdkService.getAll(Users, { diff --git a/apps/json-api-server-e2e/src/json-api/json-api-sdk/check-othe-call.spec.ts b/apps/json-api-server-e2e/src/json-api/json-api-sdk/check-othe-call.spec.ts index d6d2957a..d5079fed 100644 --- a/apps/json-api-server-e2e/src/json-api/json-api-sdk/check-othe-call.spec.ts +++ b/apps/json-api-server-e2e/src/json-api/json-api-sdk/check-othe-call.spec.ts @@ -1,22 +1,25 @@ -import { INestApplication } from '@nestjs/common'; +/** + * JSON API: Advanced Configuration - Custom Routes, Validation, and Resource Types + * + * This test suite demonstrates advanced JSON API configurations including custom route names, + * UUID-based resource IDs, custom validation pipes, and method restrictions. + * + * Examples include: + * - Using custom route names with @JsonApi({ overrideRoute: 'custom-name' }) + * - Working with UUID resource identifiers instead of numeric IDs + * - Restricting available methods on specific resources + * - Applying custom query validation pipes + * - Direct HTTP client usage for advanced scenarios + */ + import { FilterOperand, JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; import { BookList, Users } from '@nestjs-json-api/typeorm-database'; import { AxiosError } from 'axios'; import { faker } from '@faker-js/faker'; import { lastValueFrom } from 'rxjs'; -import { creatSdk, run, axiosAdapter } from '../utils/run-application'; - -let app: INestApplication; - -beforeAll(async () => { - app = await run(); -}); - -afterAll(async () => { - await app.close(); -}); +import { creatSdk, axiosAdapter } from '../utils/run-application'; -describe('Other call type:', () => { +describe('Advanced Configuration and Custom Routes', () => { let jsonSdk: JsonSdkPromise; beforeEach(async () => { @@ -27,10 +30,10 @@ describe('Other call type:', () => { afterEach(async () => {}); - describe('Check overrideRoute url name:', () => { + describe('Custom Route Names with UUID IDs', () => { let bookItem: BookList; - it('Should be be be create book', async () => { + it('should create, fetch, and delete a resource using custom route name with UUID identifier', async () => { bookItem = new BookList(); bookItem.text = faker.string.alpha(50); @@ -52,13 +55,14 @@ describe('Other call type:', () => { expect(newBook.id).toBeDefined(); const bookResultSource = await lastValueFrom( - axiosAdapter.get(`${url}/${newBook.id}`) + // By default, id is a number, but I test uuid + axiosAdapter.get(`${url}/${newBookSource.data.id}`) ); const bookResult = jsonSdk.jsonApiUtilsService.convertResponseData(bookResultSource); expect(bookResult.id).toBe(newBook.id); await lastValueFrom( - axiosAdapter.delete(`${url}/${bookResult.id}`, { + axiosAdapter.delete(`${url}/${newBookSource.data.id}`, { data: { id: bookResult.id, type: 'book-list', @@ -67,7 +71,7 @@ describe('Other call type:', () => { ); }); - it('Should be not allowed method', async () => { + it('should return error when accessing a restricted relationship endpoint', async () => { const url = jsonSdk.jsonApiUtilsService.getUrlForResource('override-book-list'); expect.assertions(1); @@ -78,7 +82,7 @@ describe('Other call type:', () => { } }); - it('Should be error if id is number', async () => { + it('should return validation error when providing numeric ID instead of UUID', async () => { const url = jsonSdk.jsonApiUtilsService.getUrlForResource('override-book-list'); expect.assertions(2); @@ -91,8 +95,8 @@ describe('Other call type:', () => { }); }); - describe('Check custom query pipe', () => { - it('Should be error from custom query pipe', async () => { + describe('Custom Query Validation Pipes', () => { + it('should trigger custom query validation pipe and return validation error', async () => { expect.assertions(2); try { await jsonSdk.jonApiSdkService.getAll(Users, { diff --git a/apps/json-api-server-e2e/src/json-api/json-api-sdk/get-method.spec.ts b/apps/json-api-server-e2e/src/json-api/json-api-sdk/get-method.spec.ts index 34df9ff4..aa5b3362 100644 --- a/apps/json-api-server-e2e/src/json-api/json-api-sdk/get-method.spec.ts +++ b/apps/json-api-server-e2e/src/json-api/json-api-sdk/get-method.spec.ts @@ -1,4 +1,3 @@ -import { INestApplication } from '@nestjs/common'; import { Addresses, CommentKind, @@ -10,19 +9,24 @@ import { faker } from '@faker-js/faker'; import { FilterOperand, JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; import { getUser } from '../utils/data-utils'; -import { creatSdk, run } from '../utils/run-application'; - -let app: INestApplication; - -beforeAll(async () => { - app = await run(); -}); - -afterAll(async () => { - await app.close(); -}); - -describe('GET method:', () => { +import { creatSdk} from '../utils/run-application'; +import { AxiosError } from 'axios'; + +/** + * JSON API: GET Operations - Fetching and Filtering Resources + * + * This test suite demonstrates how to use the JSON API SDK to fetch resources + * with various filtering, pagination, field selection, and relationship options. + * + * Examples include: + * - Basic filtering with operators (eq, ne, in, like) + * - Filtering by relationship fields + * - Pagination and sorting + * - Sparse fieldsets (selecting specific fields) + * - Including and filtering relationships + * - Fetching relationship data + */ +describe('JSON API: GET Operations - Fetching and Filtering Resources', () => { let jsonSdk: JsonSdkPromise; let usersArray: Users[]; let addressArray: Addresses[]; @@ -104,15 +108,15 @@ describe('GET method:', () => { ); }); - describe('Check filter', () => { - it('Should be get all entities', async () => { + describe('Filtering Resources', () => { + it('should fetch all users without filters', async () => { const users = await jsonSdk.jonApiSdkService.getAll(Users); expect(users).toBeDefined(); expect(users).toBeInstanceOf(Array); expect(users.length).toBeGreaterThan(0); }); - it('Should be get entities with filter', async () => { + it('should filter users by target attributes using eq, ne, in, and like operators', async () => { const users = await jsonSdk.jonApiSdkService.getAll(Users, { filter: { target: { @@ -124,6 +128,9 @@ describe('GET method:', () => { }, }, }, + }).catch((e: AxiosError) => { + console.log(e); + throw e; }); expect(users).toBeDefined(); expect(users.length).toBe(2); @@ -160,7 +167,7 @@ describe('GET method:', () => { expect(resultFindLike.at(0)?.id).toBe(users2.at(0)?.id); }); - it('Should be get entities with filter by relation target', async () => { + it('should filter users by relationship existence (null/not null check)', async () => { const users = await jsonSdk.jonApiSdkService.getAll(Users, { filter: { target: { @@ -198,7 +205,7 @@ describe('GET method:', () => { }); }); - it('Should be get entities with filter by relation', async () => { + it('should filter users by related resource attributes (roles.name)', async () => { const users = await jsonSdk.jonApiSdkService.getAll(Users, { filter: { target: { @@ -222,8 +229,8 @@ describe('GET method:', () => { }); }); - describe('Check pagination', () => { - it('Check limit', async () => { + describe('Pagination and Sorting', () => { + it('should return first page with page size limit', async () => { const users = await jsonSdk.jonApiSdkService.getList(Users, { filter: { target: { @@ -247,7 +254,7 @@ describe('GET method:', () => { expect(users.length).toBe(1); expect(users[0].id).toBe(usersArray.sort((a, b) => a.id - b.id)[0].id); }); - it('Check limit second page', async () => { + it('should return second page when page number is 2', async () => { const users = await jsonSdk.jonApiSdkService.getList(Users, { filter: { target: { @@ -272,8 +279,8 @@ describe('GET method:', () => { }); }); - describe('Check select', () => { - it('Check target field', async () => { + describe('Sparse Fieldsets (Field Selection)', () => { + it('should return only specified target fields (id, isActive)', async () => { const users = await jsonSdk.jonApiSdkService.getAll(Users, { filter: { target: { @@ -295,7 +302,7 @@ describe('GET method:', () => { expect(user.firstName).toBeUndefined(); }); }); - it('Check relation field', async () => { + it('should return specified fields for both target and related resources', async () => { const users = await jsonSdk.jonApiSdkService.getAll(Users, { filter: { target: { @@ -335,8 +342,8 @@ describe('GET method:', () => { }); }); - describe('Get relation', () => { - it('Get relation by id result string', async () => { + describe('Fetching Relationship Data', () => { + it('should return relationship identifier for to-one relationship (addresses)', async () => { const userItem = usersArray[0]; const result = await jsonSdk.jonApiSdkService.getRelationships( @@ -353,7 +360,7 @@ describe('GET method:', () => { expect(result).toBe(`${resultGetOne.addresses.id}`); }); - it('Get relation by id result string array', async () => { + it('should return relationship identifiers for to-many relationship (roles)', async () => { const userItem = usersArray.filter((i) => i.roles)[0]; const result = await jsonSdk.jonApiSdkService.getRelationships( diff --git a/apps/json-api-server-e2e/src/json-api/json-api-sdk/patch-methode.spec.ts b/apps/json-api-server-e2e/src/json-api/json-api-sdk/patch-methode.spec.ts index e603de0c..4d11df4d 100644 --- a/apps/json-api-server-e2e/src/json-api/json-api-sdk/patch-methode.spec.ts +++ b/apps/json-api-server-e2e/src/json-api/json-api-sdk/patch-methode.spec.ts @@ -1,4 +1,17 @@ -import { INestApplication } from '@nestjs/common'; +/** + * JSON API: PATCH Operations - Updating Resources + * + * This test suite demonstrates how to use the JSON API SDK to update existing resources + * including their attributes and relationships. + * + * Examples include: + * - Updating resource attributes + * - Replacing one-to-one relationships + * - Replacing one-to-many relationships + * - Partial updates (updating only relationships without modifying attributes) + * - Automatic updatedAt timestamp management + */ + import { Addresses, CommentKind, @@ -8,19 +21,9 @@ import { import { faker } from '@faker-js/faker'; import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; -import { creatSdk, run } from '../utils/run-application'; - -let app: INestApplication; - -beforeAll(async () => { - app = await run(); -}); - -afterAll(async () => { - await app.close(); -}); +import { creatSdk } from '../utils/run-application'; -describe('PATCH method:', () => { +describe('Updating Resources (PATCH Operations)', () => { let jsonSdk: JsonSdkPromise; let address: Addresses; let addressAfterSave: Addresses; @@ -66,7 +69,7 @@ describe('PATCH method:', () => { await jsonSdk.jonApiSdkService.deleteOne(newCommentsAfterSave); }); - it('Should be update attributes', async () => { + it('should update resource attributes and automatically update the updatedAt timestamp', async () => { addressAfterSave.city = faker.location.city(); const addressAfterUpdate = await jsonSdk.jonApiSdkService.patchOne( addressAfterSave @@ -75,7 +78,7 @@ describe('PATCH method:', () => { expect(addressAfterSave).toEqual(addressAfterUpdate); }); - it('Should be update attributes with relations', async () => { + it('should replace existing relationships with new resources', async () => { const newAddress = new Addresses(); newAddress.city = faker.location.city(); newAddress.state = faker.location.state(); @@ -104,7 +107,7 @@ describe('PATCH method:', () => { expect(userAfterUpdate.comments[0]).toEqual(newCommentsAfterSave); }); - it('Should be update empty attributes with relations', async () => { + it('should update only relationships without modifying other attributes using partial resource object', async () => { const newAddress = new Addresses(); newAddress.city = faker.location.city(); newAddress.state = faker.location.state(); diff --git a/apps/json-api-server-e2e/src/json-api/json-api-sdk/post-method.spec.ts b/apps/json-api-server-e2e/src/json-api/json-api-sdk/post-method.spec.ts index 78fe67f0..70be652f 100644 --- a/apps/json-api-server-e2e/src/json-api/json-api-sdk/post-method.spec.ts +++ b/apps/json-api-server-e2e/src/json-api/json-api-sdk/post-method.spec.ts @@ -1,6 +1,19 @@ +/** + * JSON API: POST Operations - Creating Resources + * + * This test suite demonstrates how to use the JSON API SDK to create new resources + * with various relationship configurations. + * + * Examples include: + * - Creating simple resources with attributes + * - Creating resources with one-to-one relationships + * - Creating resources with one-to-many relationships + * - Automatic generation of timestamps and IDs + * - Verifying relationships are properly linked + */ + import { Addresses, - BookList, CommentKind, Comments, Users, @@ -8,19 +21,9 @@ import { import { faker } from '@faker-js/faker'; import { JsonSdkPromise } from '@klerick/json-api-nestjs-sdk'; -import { creatSdk, run } from '../utils/run-application'; -import { INestApplication } from '@nestjs/common'; -let app: INestApplication; - -beforeAll(async () => { - app = await run(); -}); - -afterAll(async () => { - await app.close(); -}); +import { creatSdk } from '../utils/run-application'; -describe('POST method:', () => { +describe('Creating Resources (POST Operations)', () => { let jsonSdk: JsonSdkPromise; let address: Addresses; let addressAfterSave: Addresses; @@ -56,7 +59,7 @@ describe('POST method:', () => { await jsonSdk.jonApiSdkService.deleteOne(addressAfterSave); }); - it('Should be create Entity', async () => { + it('should create a new resource and automatically generate id and timestamps', async () => { addressAfterSave = await jsonSdk.jonApiSdkService.postOne(address); const { id: addressId, @@ -71,7 +74,7 @@ describe('POST method:', () => { expect(updatedAt).toBeInstanceOf(Date); }); - it('Should be create Entity with relations', async () => { + it('should create a resource with a one-to-one relationship and verify the relationship is properly linked', async () => { addressAfterSave = await jsonSdk.jonApiSdkService.postOne(address); user.addresses = addressAfterSave; @@ -101,7 +104,7 @@ describe('POST method:', () => { expect(addresses).toEqual(fromUser.addresses); }); - it('Should be create Entity with relations array', async () => { + it('should create a resource with both one-to-one and one-to-many relationships', async () => { addressAfterSave = await jsonSdk.jonApiSdkService.postOne(address); commentsAfterSave = await jsonSdk.jonApiSdkService.postOne(comments); user.addresses = addressAfterSave; diff --git a/apps/json-api-server-e2e/src/json-api/json-rpc/run-json-rpc.spec.ts b/apps/json-api-server-e2e/src/json-api/json-rpc/run-json-rpc.spec.ts index 3c15ada0..45268c20 100644 --- a/apps/json-api-server-e2e/src/json-api/json-rpc/run-json-rpc.spec.ts +++ b/apps/json-api-server-e2e/src/json-api/json-rpc/run-json-rpc.spec.ts @@ -1,23 +1,27 @@ -import { INestApplication } from '@nestjs/common'; +/** + * JSON-RPC 2.0: HTTP Transport Protocol Integration + * + * This test suite demonstrates JSON-RPC 2.0 protocol implementation over HTTP transport. + * It verifies that standard JSON-RPC requests, batch operations, and error handling + * conform to the JSON-RPC 2.0 specification. + * + * Examples include: + * - Single method invocations with various parameter types + * - Batch requests (multiple method calls in a single HTTP request) + * - Error handling for MethodNotFound, InvalidParams, and ServerError + * - Type-safe RPC client usage with TypeScript + * - Custom error responses with additional data + */ + import { ResultRpcFactoryPromise, ErrorCodeType, RpcError, } from '@klerick/nestjs-json-rpc-sdk'; -import { creatRpcSdk, MapperRpc, run } from '../utils/run-application'; - -let app: INestApplication; - -beforeAll(async () => { - app = await run(); -}); - -afterAll(async () => { - await app.close(); -}); +import { creatRpcSdk, MapperRpc } from '../utils/run-application'; -describe('Run json rpc:', () => { +describe('JSON-RPC 2.0 over HTTP', () => { let rpc: ResultRpcFactoryPromise['rpc']; let rpcBatch: ResultRpcFactoryPromise['rpcBatch']; let rpcForBatch: ResultRpcFactoryPromise['rpcForBatch']; @@ -25,14 +29,14 @@ describe('Run json rpc:', () => { ({ rpc, rpcBatch, rpcForBatch } = creatRpcSdk()); }); - describe('Should be correct response', () => { - it('Should be call one method', async () => { + describe('Successful RPC Calls', () => { + it('should invoke a single RPC method and return the correct result', async () => { const input = 1; const result = await rpc.RpcService.someMethode(input); expect(result).toBe(input); }); - it('Should be correct response batch', async () => { + it('should execute multiple RPC methods in a single batch request', async () => { const input = 1; const input2 = { a: 1, @@ -51,8 +55,8 @@ describe('Run json rpc:', () => { }); }); - describe('Check error', () => { - it('Should throw an error ' + ErrorCodeType.MethodNotFound, async () => { + describe('Error Handling', () => { + it('should return MethodNotFound error (-32601) when calling non-existent service or method', async () => { const input = 1; expect.assertions(6); try { @@ -73,7 +77,7 @@ describe('Run json rpc:', () => { } }); - it('Should throw an error ' + ErrorCodeType.InvalidParams, async () => { + it('should return InvalidParams error (-32602) when providing incorrect parameter types', async () => { const input = 'llll'; expect.assertions(3); try { @@ -86,7 +90,7 @@ describe('Run json rpc:', () => { } }); - it('Should throw an error ' + ErrorCodeType.ServerError, async () => { + it('should return ServerError (-32099) with custom error data when method throws an exception', async () => { const input = 5; expect.assertions(4); try { diff --git a/apps/json-api-server-e2e/src/json-api/json-rpc/run-ws-json-rpc.spec.ts b/apps/json-api-server-e2e/src/json-api/json-rpc/run-ws-json-rpc.spec.ts index 09803e17..db111e91 100644 --- a/apps/json-api-server-e2e/src/json-api/json-rpc/run-ws-json-rpc.spec.ts +++ b/apps/json-api-server-e2e/src/json-api/json-rpc/run-ws-json-rpc.spec.ts @@ -1,4 +1,19 @@ -import { INestApplication } from '@nestjs/common'; +/** + * JSON-RPC 2.0: WebSocket Transport Protocol Integration + * + * This test suite demonstrates JSON-RPC 2.0 protocol implementation over WebSocket transport. + * WebSocket provides bidirectional, full-duplex communication for real-time RPC operations. + * It verifies that standard JSON-RPC requests, batch operations, and error handling work + * correctly over persistent WebSocket connections. + * + * Examples include: + * - Single method invocations over WebSocket connection + * - Batch requests (multiple method calls in a single WebSocket message) + * - Error handling for MethodNotFound, InvalidParams, and ServerError + * - Type-safe RPC client usage with TypeScript + * - Connection lifecycle management and cleanup + */ + import { ResultRpcFactoryPromise, ErrorCodeType, @@ -8,23 +23,15 @@ import { import { creatWsRpcSdk, MapperRpc, - run, destroySubject, } from '../utils/run-application'; -let app: INestApplication; - -beforeAll(async () => { - app = await run(); -}); - afterAll(async () => { destroySubject.next(true); destroySubject.complete(); - await app.close(); }); -describe('Run ws json rpc:', () => { +describe('JSON-RPC 2.0 over WebSocket', () => { let rpc: ResultRpcFactoryPromise['rpc']; let rpcBatch: ResultRpcFactoryPromise['rpcBatch']; let rpcForBatch: ResultRpcFactoryPromise['rpcForBatch']; @@ -32,14 +39,14 @@ describe('Run ws json rpc:', () => { ({ rpc, rpcBatch, rpcForBatch } = creatWsRpcSdk()); }); - describe('Should be correct response', () => { - it('Should be call one method', async () => { + describe('Successful RPC Calls', () => { + it('should invoke a single RPC method and return the correct result', async () => { const input = 1; const result = await rpc.RpcService.someMethode(input); expect(result).toBe(input); }); - it('Should be correct response batch', async () => { + it('should execute multiple RPC methods in a single batch request', async () => { const input = 1; const input2 = { a: 1, @@ -58,8 +65,8 @@ describe('Run ws json rpc:', () => { }); }); - describe('Check error', () => { - it('Should throw an error ' + ErrorCodeType.MethodNotFound, async () => { + describe('Error Handling', () => { + it('should return MethodNotFound error (-32601) when calling non-existent service or method', async () => { const input = 1; expect.assertions(6); try { @@ -80,7 +87,7 @@ describe('Run ws json rpc:', () => { } }); - it('Should throw an error ' + ErrorCodeType.InvalidParams, async () => { + it('should return InvalidParams error (-32602) when providing incorrect parameter types', async () => { const input = 'llll'; expect.assertions(3); try { @@ -93,7 +100,7 @@ describe('Run ws json rpc:', () => { } }); - it('Should throw an error ' + ErrorCodeType.ServerError, async () => { + it('should return ServerError (-32099) with custom error data when method throws an exception', async () => { const input = 5; expect.assertions(4); try { diff --git a/apps/json-api-server-e2e/src/json-api/utils/acl/acl.ts b/apps/json-api-server-e2e/src/json-api/utils/acl/acl.ts new file mode 100644 index 00000000..b0c459f4 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/utils/acl/acl.ts @@ -0,0 +1,72 @@ +import { + AbilityBuilder as NativeAbilityBuilder, + AbilityTuple, + createMongoAbility, + InferSubjects, + MongoAbility, +} from '@casl/ability'; +import { JsonBaseController } from '@klerick/json-api-nestjs'; +import { aclRegistry, allType } from './microorm'; + +export * from './data-test' + +import { UserRole } from '@nestjs-json-api/microorm-database'; + +type ClassNames = keyof typeof aclRegistry; +type EntityType = (typeof allType)[number]; + + +type SelectType = `${ClassNames}`; + +export type DefaultSubjects = InferSubjects | SelectType; +export type DefaultActions = keyof JsonBaseController; + +type TupleAbility< + Subjects extends DefaultSubjects = DefaultSubjects, + Actions extends string = DefaultActions, +> = { + [K in Actions]: AbilityTuple; +}; +type ValueOf = T[keyof T]; + +type Ability< + Subjects extends DefaultSubjects = DefaultSubjects, + Actions extends string = DefaultActions +> = ValueOf>; + + +export class AbilityBuilder< + Roles extends string, + Subjects extends DefaultSubjects = DefaultSubjects, + Actions extends string = DefaultActions +> extends NativeAbilityBuilder>> { + constructor( + private readonly permissions: Permissions + ) { + super(createMongoAbility); + } + + extend = (role: Roles): void => { + this.permissionsFor(role); + }; + + permissionsFor(role: Roles): this { + const rolePermissions = this.permissions[role]; + if (rolePermissions) { + rolePermissions(this); + } + return this; + } +} + +export type DefinePermissions< + Roles extends string, + Subjects extends DefaultSubjects = DefaultSubjects, + Actions extends string = DefaultActions +> = (builder: AbilityBuilder) => void; + +export type Permissions< + Roles extends string = UserRole, + Subjects extends DefaultSubjects = DefaultSubjects, + Actions extends string = DefaultActions +> = Partial>>; diff --git a/apps/json-api-server-e2e/src/json-api/utils/acl/data-test/get-acl-check.ts b/apps/json-api-server-e2e/src/json-api/utils/acl/data-test/get-acl-check.ts new file mode 100644 index 00000000..74a8fdb9 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/utils/acl/data-test/get-acl-check.ts @@ -0,0 +1,189 @@ +import { Permissions } from '../acl'; + +export const CheckFieldAndInclude: Permissions = { + admin({ can, cannot }) { + can('getAll', 'UserProfileAcl'); + can('getAll', 'UsersAcl'); + can('getOne', 'UsersAcl'); + can('getOne', 'UserProfileAcl'); + can('deleteOne', 'ArticleAcl'); + can('postOne', 'ArticleAcl'); + can('patchOne', 'ArticleAcl'); + can('getRelationship', 'UsersAcl', ['profile', 'posts']); + can('deleteRelationship', 'UsersAcl', ['posts', 'aclComments']); + can('postRelationship', 'UsersAcl', ['posts', 'aclComments']); + can('patchRelationship', 'UsersAcl', ['posts', 'aclComments']); + }, + user({ can, cannot }) { + can( + 'getAll', + 'UserProfileAcl', + ['firstName', 'lastName', 'bio', 'avatar', 'phone'], + { + 'user.id': { + $eq: '${currentUser.id}' + }, + } + ); + can( + 'getAll', + 'UserProfileAcl', + ['firstName', 'lastName', 'bio', 'avatar'], + { + isPublic: { + $eq: true, + }, + } + ); + can( + 'getOne', + 'UserProfileAcl', + ['firstName', 'lastName', 'bio', 'avatar', 'phone'], + { + 'user.id': { + $eq: '${currentUser.id}' + }, + } + ); + can( + 'getOne', + 'UserProfileAcl', + ['firstName', 'lastName', 'bio', 'avatar'], + { + isPublic: { + $eq: true, + }, + } + ); + can('deleteOne', 'ArticleAcl', { + 'author.id': { + $eq: '${currentUser.id}' + }, + status: { + $ne: 'published' + }, + }); + can('postOne', 'ArticleAcl', { + 'author.id': { + $eq: '${currentUser.id}' + }, + status: { + $ne: 'published' + }, + }); + can('patchOne', 'ArticleAcl', ['coAuthorIds'], { + '__current.coAuthorIds': { + $in: ['${currentUser.id}'] + }, + 'coAuthorIds': { + $all: '${removeMyselfOnly(@input.__current.coAuthorIds, currentUser.id)}', + $size: '${@input.__current.coAuthorIds.length - 1}' + } + }); + can('patchOne', 'ArticleAcl', { + 'author.id': { + $eq: '${currentUser.id}' + }, + }); + can('getRelationship', 'UsersAcl', ['posts', 'profile'], { + 'id': { + $eq: '${currentUser.id}' + }, + }); + can('deleteRelationship', 'UsersAcl', ['aclComments'], { + 'id': { + $eq: '${currentUser.id}' + }, + }); + can('postRelationship', 'UsersAcl', ['aclComments'], { + 'id': { + $eq: '${currentUser.id}' + }, + }); + can('patchRelationship', 'UsersAcl', ['aclComments'], { + 'id': { + $eq: '${currentUser.id}' + }, + }); + }, + moderator({ can, cannot }) { + can('getAll', 'UsersAcl', [ + '*', + 'posts.*', + 'aclComments.*', + 'createdTags.*', + 'authoredArticles.*', + 'editedArticles.*', + 'documents.*', + 'profile.id', + 'profile.firstName', + 'profile.lastName', + 'profile.bio', + 'profile.avatar', + 'profile.phone', + 'profile.isPublic', + 'profile.role', + 'profile.createdAt', + 'profile.updatedAt', + ]); + can('getAll', 'UserProfileAcl', [ + 'firstName', + 'lastName', + 'bio', + 'avatar', + 'phone', + 'createdAt', + 'updatedAt' + ]); + can('getOne', 'UsersAcl', [ + '*', + 'posts.*', + 'aclComments.*', + 'createdTags.*', + 'authoredArticles.*', + 'editedArticles.*', + 'documents.*', + 'profile.id', + 'profile.firstName', + 'profile.lastName', + 'profile.bio', + 'profile.avatar', + 'profile.phone', + 'profile.isPublic', + 'profile.role', + 'profile.createdAt', + 'profile.updatedAt', + ]) + can('getOne', 'UserProfileAcl', [ + 'firstName', + 'lastName', + 'bio', + 'avatar', + 'phone', + 'createdAt', + 'updatedAt' + ]); + can('deleteOne', 'ArticleAcl', { + status: { + "$in": ["published", "draft"] + }, + }); + can('postOne', 'ArticleAcl', { + 'author.id': { + $eq: '${currentUser.id}' + } + }); + can('patchOne', 'ArticleAcl', ['status'], { + status: { + $ne: 'published' + } + }); + can('patchOne', 'ArticleAcl', ['status'], { + status: 'review' + }); + can('getRelationship', 'UsersAcl', ['posts']); + can('deleteRelationship', 'UsersAcl', ['posts']); + can('postRelationship', 'UsersAcl', ['posts']); + can('patchRelationship', 'UsersAcl', ['posts']); + }, +}; diff --git a/apps/json-api-server-e2e/src/json-api/utils/acl/data-test/index.ts b/apps/json-api-server-e2e/src/json-api/utils/acl/data-test/index.ts new file mode 100644 index 00000000..1c5a864e --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/utils/acl/data-test/index.ts @@ -0,0 +1 @@ +export * from './get-acl-check' diff --git a/apps/json-api-server-e2e/src/json-api/utils/acl/microorm.ts b/apps/json-api-server-e2e/src/json-api/utils/acl/microorm.ts new file mode 100644 index 00000000..cf4c93c6 --- /dev/null +++ b/apps/json-api-server-e2e/src/json-api/utils/acl/microorm.ts @@ -0,0 +1,34 @@ +import { + UsersAcl, + ArticleAcl, + CategoryAcl, + TagAcl, + PostAcl, + CommentAcl, + DocumentAcl, + UserProfileAcl, +} from '@nestjs-json-api/microorm-database'; + +export const allType = [ + UsersAcl, + ArticleAcl, + CategoryAcl, + TagAcl, + PostAcl, + CommentAcl, + DocumentAcl, + UserProfileAcl, +] as const; + + +export const aclRegistry = { + UsersAcl, + ArticleAcl, + CategoryAcl, + TagAcl, + PostAcl, + CommentAcl, + DocumentAcl, + UserProfileAcl, +} as const; + diff --git a/apps/json-api-server-e2e/src/json-api/utils/data-utils.ts b/apps/json-api-server-e2e/src/json-api/utils/data-utils.ts index 7e4cef3b..2469911d 100644 --- a/apps/json-api-server-e2e/src/json-api/utils/data-utils.ts +++ b/apps/json-api-server-e2e/src/json-api/utils/data-utils.ts @@ -2,6 +2,7 @@ import { Users } from '@nestjs-json-api/typeorm-database'; import { faker } from '@faker-js/faker'; export const getUser = () => { + const user = new Users(); user.firstName = faker.string.alpha(50); user.lastName = faker.string.alpha(50); diff --git a/apps/json-api-server-e2e/src/json-api/utils/run-application.ts b/apps/json-api-server-e2e/src/json-api/utils/run-application.ts index ecb15d6d..d0090893 100644 --- a/apps/json-api-server-e2e/src/json-api/utils/run-application.ts +++ b/apps/json-api-server-e2e/src/json-api/utils/run-application.ts @@ -1,6 +1,4 @@ -import { Test } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { adapterForAxios, JsonApiJs } from '@klerick/json-api-nestjs-sdk'; +import { adapterForAxios, JsonApiJs, JsonConfig } from '@klerick/json-api-nestjs-sdk'; import { RpcFactory, axiosTransportFactory, @@ -9,41 +7,14 @@ import { import { RpcService } from '@nestjs-json-api/type-for-rpc'; import { TransportType } from '@klerick/nestjs-json-rpc-sdk'; import axios from 'axios'; -import { Logger } from 'nestjs-pino'; import { WebSocket } from 'ws'; -import { AppModule } from '../../../../json-api-server/src/app/app.module'; - -import { JsonConfig } from '../../../../../libs/json-api/json-api-nestjs-sdk/src/lib/types'; -import { WsAdapter } from '@nestjs/platform-ws'; import { Subject } from 'rxjs'; -import { NestExpressApplication } from '@nestjs/platform-express'; export const axiosAdapter = adapterForAxios(axios); -let saveApp: INestApplication; export const port = 3000; export const globalPrefix = 'api'; -export const run = async () => { - if (saveApp) return saveApp; - const moduleRef = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - const app = moduleRef.createNestApplication({ - bufferLogs: true, - logger: false, - }); - app.useLogger(app.get(Logger)); - // const app = await NestFactory.create(AppModule); - app.setGlobalPrefix(globalPrefix); - app.useWebSocketAdapter(new WsAdapter(app)); - app.set('query parser', 'extended'); - await app.init(); - await app.listen(port); - - saveApp = app; - return app; -}; export const creatSdk = (config: Partial = {}) => JsonApiJs( diff --git a/apps/json-api-server-e2e/src/support/global-setup.ts b/apps/json-api-server-e2e/src/support/global-setup.ts index fe8392d2..4d5a1e74 100644 --- a/apps/json-api-server-e2e/src/support/global-setup.ts +++ b/apps/json-api-server-e2e/src/support/global-setup.ts @@ -1,9 +1,115 @@ /* eslint-disable */ -export default async function() { +import { killPort, waitForPortOpen } from '@nx/node/utils'; +import { logger, workspaceRoot } from '@nx/devkit'; +import { spawn, execSync, ChildProcess } from 'node:child_process'; +import { rmSync } from 'node:fs'; +import { join } from 'node:path'; + +export async function setup() { // Start services that that the app needs to run (e.g. database, docker-compose, etc.). - console.log('\nSetting up...\n'); - // Hint: Use `globalThis` to pass variables to global teardown. + const isNxRuner = !!process.env.NX_INVOKED_BY_RUNNER; + + logger.info('\nSetting up...\n'); + if (isNxRuner) { + let target: 'microorm' | 'typeorm'; + switch (process.env.NX_TASK_TARGET_TARGET) { + case 'e2e-micro': + target = 'microorm'; + break; + case 'e2e': + target = 'typeorm'; + break; + default: + throw new Error('Unknown target'); + } + + logger.info('\nRuning migration for "' + target + '"...\n'); + logger.info( + '\nRemove data folder "' + + join(workspaceRoot, './tmp/pg-test/', target) + + '"...\n' + ); + rmSync(join(workspaceRoot, './tmp/pg-test/', target), { + recursive: true, + force: true, + }); + execSync('npm run ' + target + ':up', { + env: { + ...process.env, + }, + stdio: 'inherit', + }); + + execSync('npm run ' + target + ':seeder', { + env: { + ...process.env, + }, + stdio: 'inherit', + }); + + logger.info('\nStarting server "' + target + '"...\n'); + + const server = spawn('npx', ['nx', 'serve', 'json-api-server'], { + env: { + ...process.env, + TYPE_ORM: target, + }, + detached: true, + stdio: 'ignore', + }); + + server.unref(); + + // @ts-ignore + globalThis.__SERVER_PROCESS__ = server; + // @ts-ignore + globalThis.__SERVER_PID__ = server.pid; + } else { + logger.info('\nRun from IDE\n'); + } + const host = process.env.HOST ?? 'localhost'; + const port = process.env.PORT ? Number(process.env.PORT) : 3000; + await waitForPortOpen(port, { host }); + + // @ts-ignore globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'; } +export async function teardown() { + const server = Reflect.get(globalThis, '__SERVER_PROCESS__'); + const serverPid = Reflect.get(globalThis, '__SERVER_PID__'); + const isNxRuner = !!process.env.NX_INVOKED_BY_RUNNER; + if (isNxRuner) { + // @ts-ignore + if (server && server instanceof ChildProcess && serverPid) { + try { + server.kill('SIGTERM'); + logger.info(`Sent SIGTERM to process ${serverPid}`); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + process.kill(serverPid, 0); + server.kill('SIGKILL'); + logger.info(`Sent SIGKILL to process ${serverPid}`); + } catch (e) { + logger.info(`Process ${serverPid} has been terminated`); + } + } catch (error) { + logger.warn(`Failed to kill process: ${error}`); + } + } + + const port = process.env.PORT ? Number(process.env.PORT) : 3000; + try { + await killPort(port); + logger.info(`Port ${port} has been freed`); + } catch (error) { + logger.warn(`Failed to kill port ${port}: ${error}`); + } + } + + // @ts-ignore + logger.info(globalThis.__TEARDOWN_MESSAGE__); +} diff --git a/apps/json-api-server-e2e/src/support/global-teardown.ts b/apps/json-api-server-e2e/src/support/global-teardown.ts deleted file mode 100644 index cfb0bdfe..00000000 --- a/apps/json-api-server-e2e/src/support/global-teardown.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable */ - -export default async function() { - // Put clean up logic here (e.g. stopping services, docker-compose, etc.). - // Hint: `globalThis` is shared between setup and teardown. - console.log(globalThis.__TEARDOWN_MESSAGE__); -} diff --git a/apps/json-api-server-e2e/tsconfig.spec.json b/apps/json-api-server-e2e/tsconfig.spec.json index f6ea399e..0bc6f0c1 100644 --- a/apps/json-api-server-e2e/tsconfig.spec.json +++ b/apps/json-api-server-e2e/tsconfig.spec.json @@ -24,6 +24,9 @@ "@nestjs-json-api/microorm-database": [ "libs/microorm-database/src/index.ts" ], + "@nestjs-json-api/microorm-database/entity": [ + "libs/microorm-database/src/lib/entities/index.ts" + ], "@nestjs-json-api/type-for-rpc": ["libs/type-for-rpc/src/index.ts"], "@nestjs-json-api/typeorm-database": ["libs/typeorm-database/src/index.ts"], "@klerick/json-api-nestjs": ["dist/libs/json-api/json-api-nestjs"], @@ -53,6 +56,7 @@ "vite.config.mts", "vitest.config.ts", "vitest.config.mts", + "src/**/*.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts" diff --git a/apps/json-api-server-e2e/vite.config.mts b/apps/json-api-server-e2e/vite.config.mts index 4b6babdf..4bca3aaa 100644 --- a/apps/json-api-server-e2e/vite.config.mts +++ b/apps/json-api-server-e2e/vite.config.mts @@ -3,39 +3,26 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import swc from 'unplugin-swc'; import { defineConfig } from 'vite'; -import path from 'node:path'; +import { BaseSequencer } from 'vitest/node'; + +class CustomSequencer extends BaseSequencer { + async sort(files: any[]) { + return [...files].sort((a, b) => { + const pathA = typeof a === 'string' ? a : a[1]; + const pathB = typeof b === 'string' ? b : b[1]; + return pathA.localeCompare(pathB); + }); + } +} export default defineConfig(() => ({ root: __dirname, cacheDir: '../../node_modules/.vite/apps/json-api-server-e2e', - // resolve: { - // alias: { - // '@klerick/json-api-nestjs': path.resolve( - // __dirname, - // '../../dist/libs/json-api/json-api-nestjs' - // ), - // '@klerick/json-api-nestjs-microorm': path.resolve( - // __dirname, - // '../../dist/libs/json-api/json-api-nestjs-microorm' - // ), - // '@klerick/json-api-nestjs-shared': path.resolve( - // __dirname, - // '../../dist/libs/json-api/json-api-nestjs-shared' - // ), - // '@klerick/json-api-nestjs-typeorm': path.resolve( - // __dirname, - // '../../dist/libs/json-api/json-api-nestjs-typeorm' - // ), - // '@klerick/json-api-nestjs-sdk': path.resolve( - // __dirname, - // '../../dist/libs/json-api/json-api-nestjs-sdk' - // ), - // }, - // }, plugins: [ nxViteTsPaths(), swc.vite({ module: { type: 'es6' }, + tsconfigFile: './tsconfig.spec.json', jsc: { target: 'es2022', parser: { @@ -52,6 +39,9 @@ export default defineConfig(() => ({ }, }), ], + optimizeDeps: { + exclude: ['@electric-sql/pglite'], + }, test: { name: 'json-api-server-e2e', watch: false, @@ -69,14 +59,18 @@ export default defineConfig(() => ({ provider: 'v8' as const, }, fileParallelism: false, - pool: 'threads', - singleThread: true, + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, sequence: { + sequencer: CustomSequencer, concurrent: false, shuffle: false, }, - maxWorkers: 1, - minWorkers: 1, + maxConcurrency: 1, env: { VITEST: 'true', NODE_ENV: 'test', diff --git a/apps/json-api-server/project.json b/apps/json-api-server/project.json index 2d9dd3e8..38bf3f25 100644 --- a/apps/json-api-server/project.json +++ b/apps/json-api-server/project.json @@ -61,6 +61,26 @@ "buildTarget": "json-api-server:build:production" } } + }, + "serve-microorm": { + "continuous": true, + "executor": "nx:run-commands", + "options": { + "command": "npx nx run json-api-server:serve", + "env": { + "ORM_TYPE": "microorm" + } + } + }, + "serve-typeorm": { + "continuous": true, + "executor": "nx:run-commands", + "options": { + "command": "npx nx run json-api-server:serve", + "env": { + "ORM_TYPE": "typeorm" + } + } } }, "tags": [] diff --git a/apps/json-api-server/src/app/app.module.ts b/apps/json-api-server/src/app/app.module.ts index 6d200c5f..aa2ab8ef 100644 --- a/apps/json-api-server/src/app/app.module.ts +++ b/apps/json-api-server/src/app/app.module.ts @@ -1,34 +1,64 @@ import { Module } from '@nestjs/common'; -import { LoggerModule } from 'nestjs-pino'; +import { LoggerErrorInterceptor, LoggerModule } from 'nestjs-pino'; +import { ClsModule, ClsServiceManager } from 'nestjs-cls'; -import { TypeOrmDatabaseModule } from '@nestjs-json-api/typeorm-database'; -import { MicroOrmDatabaseModule } from '@nestjs-json-api/microorm-database'; -import { ResourcesTypeModule } from './resources/type-orm/resources-type.module'; -import { ResourcesMicroModule } from './resources/micro-orm/resources-micro.module'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { RpcModule } from './rpc/rpc.module'; -const ormModule = - process.env['ORM_TYPE'] === 'typeorm' - ? TypeOrmDatabaseModule - : MicroOrmDatabaseModule; +import {JsonApiTypeOrm, JsonApiMicroOrm} from './json-api' -const resourceModule = +const jsonAPiModule = process.env['ORM_TYPE'] === 'typeorm' - ? ResourcesTypeModule - : ResourcesMicroModule; + ? JsonApiTypeOrm + : JsonApiMicroOrm; + @Module({ imports: [ - ormModule, - resourceModule, + jsonAPiModule, + ClsModule.forRoot({ + global: true, + middleware: { mount: true, generateId: true }, + }), RpcModule, LoggerModule.forRoot({ pinoHttp: { level: process.env['NODE_ENV'] === 'test' ? 'silent' : 'debug', + transport: { + target: 'pino-pretty', + options: { + translateTime: 'UTC:mm/dd/yyyy, h:MM:ss TT Z', + levelFirst: true, + colorize: true, + messageFormat: `{service} - {if context}[{context}]{end} {msg} {if requestId}({requestId}){end}`, + ignore: `hostname,service,context,message,requestId`, + }, + }, + genReqId: function (req, res) { + const cls = ClsServiceManager.getClsService(); + const existingID = cls.getId() + res.setHeader('X-Request-Id', existingID) + return existingID + }, + customProps: () => { + const cls = ClsServiceManager.getClsService(); + const contextId = cls.getId(); + if (!contextId) { + return {}; + } + return { + ['requestId']: contextId, + }; + } }, }), ], controllers: [], - providers: [], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: LoggerErrorInterceptor, + }, + ], }) export class AppModule {} diff --git a/apps/json-api-server/src/app/json-api/index.ts b/apps/json-api-server/src/app/json-api/index.ts new file mode 100644 index 00000000..93710e7c --- /dev/null +++ b/apps/json-api-server/src/app/json-api/index.ts @@ -0,0 +1,2 @@ +export { JsonApiTypeOrm } from './type-orm/json-api-type-orm'; +export { JsonApiMicroOrm } from './micro-orm/json-api-micro-orm'; diff --git a/apps/json-api-server/src/app/json-api/micro-orm/acl/acl.module.ts b/apps/json-api-server/src/app/json-api/micro-orm/acl/acl.module.ts new file mode 100644 index 00000000..2886cf19 --- /dev/null +++ b/apps/json-api-server/src/app/json-api/micro-orm/acl/acl.module.ts @@ -0,0 +1,22 @@ +import { Global, Module, OnModuleInit } from '@nestjs/common'; +import { AclPermissionsModule } from '@klerick/acl-json-api-nestjs'; +import { ClsService } from 'nestjs-cls'; +import { RulesLoaderService } from './rules-loader.service'; + +import { + ContextTestAcl, + MicroOrmDatabaseModule, +} from '@nestjs-json-api/microorm-database'; + +@Module({ + imports: [ + AclPermissionsModule.forRoot({ + rulesLoader: RulesLoaderService, + contextStore: ClsService, + onNoRules: 'allow', + }), + MicroOrmDatabaseModule.forFeature([ContextTestAcl], 'default'), + ], + providers: [RulesLoaderService], +}) +export class AclModule {} diff --git a/apps/json-api-server/src/app/json-api/micro-orm/acl/rules-loader.service.ts b/apps/json-api-server/src/app/json-api/micro-orm/acl/rules-loader.service.ts new file mode 100644 index 00000000..2f61829a --- /dev/null +++ b/apps/json-api-server/src/app/json-api/micro-orm/acl/rules-loader.service.ts @@ -0,0 +1,45 @@ +import { + AclAction, + AclRule, + AclRulesLoader, + AclSubject, +} from '@klerick/acl-json-api-nestjs'; +import { + ContextTestAcl, +} from '@nestjs-json-api/microorm-database'; +import { AnyEntity, EntityRepository } from '@mikro-orm/core'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@mikro-orm/nestjs'; + +@Injectable() +export class RulesLoaderService implements AclRulesLoader { + @InjectRepository(ContextTestAcl, 'default') + private readonly contextTestAcl!: EntityRepository; + + async getContext(): Promise<{ [x: string]: any }> { + const contexts = await this.contextTestAcl.findAll(); + const context = contexts.at(0); + if (!context) return {} + return context.context; + } + async getHelpers(): Promise<{ [x: string]: (...arg: any[]) => any }> { + return { + removeMyselfOnly: (oldArray: number[], userId: number): number[] => { + return oldArray.filter(id => id !== userId); + } + }; + } + async loadRules( + entity: AclSubject, + action: AclAction + ): Promise[]> { + const contexts = await this.contextTestAcl.findAll(); + const context = contexts.at(0); + if (!context) return [] + + const rules = context.aclRules.rules as unknown as AclRule[] + + return rules.filter(r => r.action === action && (entity as any)['name'] === r.subject) + + } +} diff --git a/apps/json-api-server/src/app/json-api/micro-orm/json-api-micro-orm.ts b/apps/json-api-server/src/app/json-api/micro-orm/json-api-micro-orm.ts new file mode 100644 index 00000000..35a85dc7 --- /dev/null +++ b/apps/json-api-server/src/app/json-api/micro-orm/json-api-micro-orm.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MicroOrmDatabaseModule } from '@nestjs-json-api/microorm-database'; + +import { AclModule } from './acl/acl.module'; +import { ResourcesMicroModule } from './micro-orm/resources-micro.module'; + +@Module({ + imports: [MicroOrmDatabaseModule.forRoot(), AclModule, ResourcesMicroModule], +}) +export class JsonApiMicroOrm {} diff --git a/apps/json-api-server/src/app/resources/micro-orm/controllers/extend-book-list/extend-book-list.controller.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/controllers/extend-book-list/extend-book-list.controller.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/controllers/extend-book-list/extend-book-list.controller.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/controllers/extend-book-list/extend-book-list.controller.ts diff --git a/apps/json-api-server/src/app/resources/micro-orm/controllers/extend-user/extend-user.controller.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/controllers/extend-user/extend-user.controller.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/controllers/extend-user/extend-user.controller.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/controllers/extend-user/extend-user.controller.ts diff --git a/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/resources-micro.module.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/resources-micro.module.ts new file mode 100644 index 00000000..ea87f556 --- /dev/null +++ b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/resources-micro.module.ts @@ -0,0 +1,73 @@ +import { Module, Type } from '@nestjs/common'; +import { JsonApiModule } from '@klerick/json-api-nestjs'; +import { MicroOrmJsonApiModule } from '@klerick/json-api-nestjs-microorm'; +import { + Users, + Addresses, + Comments, + Roles, + BookList, +} from '@nestjs-json-api/microorm-database'; + +import { + UsersAcl, + ArticleAcl, + CategoryAcl, + TagAcl, + PostAcl, + CommentAcl, + DocumentAcl, + UserProfileAcl, + ContextTestAcl, +} from '@nestjs-json-api/microorm-database'; + +const GeneraleResource = [ + Users, + Addresses, + Comments, + Roles, + BookList, + ContextTestAcl, +]; +const AclResource = [ + UsersAcl, + ArticleAcl, + CategoryAcl, + TagAcl, + PostAcl, + CommentAcl, + DocumentAcl, + UserProfileAcl, +]; + +import { ExtendBookListController } from './controllers/extend-book-list/extend-book-list.controller'; +import { ExtendUserController } from './controllers/extend-user/extend-user.controller'; +import { ExampleService } from './service/example.service'; + +import { wrapperJsonApiController } from '@klerick/acl-json-api-nestjs'; + +@Module({ + imports: [ + JsonApiModule.forRoot(MicroOrmJsonApiModule, { + entities: [...GeneraleResource, ...AclResource] as any, + controllers: [ExtendBookListController, ExtendUserController], + providers: [ExampleService], + options: { + debug: true, + requiredSelectField: false, + operationUrl: 'operation', + }, + hooks: { + afterCreateController: (controllerClass: Type) => { + if ( + !controllerClass.name.startsWith('ContextTestAcl') && + controllerClass.name.indexOf('Acl') > -1 + ) { + wrapperJsonApiController(controllerClass); + } + }, + }, + }), + ], +}) +export class ResourcesMicroModule {} diff --git a/apps/json-api-server/src/app/resources/micro-orm/service/atomic.interceptor.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/atomic.interceptor.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/service/atomic.interceptor.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/atomic.interceptor.ts diff --git a/apps/json-api-server/src/app/resources/micro-orm/service/controller.interceptor.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/controller.interceptor.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/service/controller.interceptor.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/controller.interceptor.ts diff --git a/apps/json-api-server/src/app/resources/micro-orm/service/example.pipe.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/example.pipe.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/service/example.pipe.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/example.pipe.ts diff --git a/apps/json-api-server/src/app/resources/micro-orm/service/example.service.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/example.service.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/service/example.service.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/example.service.ts diff --git a/apps/json-api-server/src/app/resources/micro-orm/service/guard.service.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/guard.service.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/service/guard.service.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/guard.service.ts diff --git a/apps/json-api-server/src/app/resources/micro-orm/service/http-exception.filter.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/http-exception.filter.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/service/http-exception.filter.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/http-exception.filter.ts diff --git a/apps/json-api-server/src/app/resources/micro-orm/service/method.interceptor.ts b/apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/method.interceptor.ts similarity index 100% rename from apps/json-api-server/src/app/resources/micro-orm/service/method.interceptor.ts rename to apps/json-api-server/src/app/json-api/micro-orm/micro-orm/service/method.interceptor.ts diff --git a/apps/json-api-server/src/app/json-api/type-orm/acl/acl.module.ts b/apps/json-api-server/src/app/json-api/type-orm/acl/acl.module.ts new file mode 100644 index 00000000..0fa428c2 --- /dev/null +++ b/apps/json-api-server/src/app/json-api/type-orm/acl/acl.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { AclPermissionsModule } from '@klerick/acl-json-api-nestjs'; +import { ClsService } from 'nestjs-cls'; +import { RulesLoaderService } from './rules-loader.service'; + +import { + ContextTestAcl, + TypeOrmDatabaseModule +} from '@nestjs-json-api/typeorm-database'; + +@Module({ + imports: [ + AclPermissionsModule.forRoot({ + rulesLoader: RulesLoaderService, + contextStore: ClsService, + onNoRules: 'allow', + }), + TypeOrmDatabaseModule.forFeature([ContextTestAcl]), + ], + providers: [RulesLoaderService], +}) +export class AclModule {} diff --git a/apps/json-api-server/src/app/json-api/type-orm/acl/rules-loader.service.ts b/apps/json-api-server/src/app/json-api/type-orm/acl/rules-loader.service.ts new file mode 100644 index 00000000..05d285ce --- /dev/null +++ b/apps/json-api-server/src/app/json-api/type-orm/acl/rules-loader.service.ts @@ -0,0 +1,45 @@ +import { + AclAction, + AclRule, + AclRulesLoader, + AclSubject, +} from '@klerick/acl-json-api-nestjs'; +import { + ContextTestAcl, +} from '@nestjs-json-api/typeorm-database'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository, } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +@Injectable() +export class RulesLoaderService implements AclRulesLoader { + @InjectRepository(ContextTestAcl, 'default') + private readonly contextTestAcl!: Repository; + + async getContext(): Promise<{ [x: string]: any }> { + const contexts = await this.contextTestAcl.find(); + const context = contexts.at(0); + if (!context) return {} + return context.context; + } + async getHelpers(): Promise<{ [x: string]: (...arg: any[]) => any }> { + return { + removeMyselfOnly: (oldArray: number[], userId: number): number[] => { + return oldArray.filter(id => id !== userId); + } + }; + } + async loadRules( + entity: AclSubject, + action: AclAction + ): Promise[]> { + const contexts = await this.contextTestAcl.find(); + const context = contexts.at(0); + if (!context) return [] + + const rules = context.aclRules.rules as unknown as AclRule[] + + return rules.filter(r => r.action === action && (entity as any)['name'] === r.subject) + + } +} diff --git a/apps/json-api-server/src/app/json-api/type-orm/json-api-type-orm.ts b/apps/json-api-server/src/app/json-api/type-orm/json-api-type-orm.ts new file mode 100644 index 00000000..cfc887e3 --- /dev/null +++ b/apps/json-api-server/src/app/json-api/type-orm/json-api-type-orm.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmDatabaseModule } from '@nestjs-json-api/typeorm-database'; + +import { AclModule } from './acl/acl.module'; +import { ResourcesTypeModule } from './type-orm/resources-type.module'; + + +@Module({ + imports: [ + TypeOrmDatabaseModule.forRoot(), + AclModule, + ResourcesTypeModule + ], +}) +export class JsonApiTypeOrm {} diff --git a/apps/json-api-server/src/app/resources/type-orm/controllers/extend-book-list/extend-book-list.controller.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/controllers/extend-book-list/extend-book-list.controller.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/controllers/extend-book-list/extend-book-list.controller.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/controllers/extend-book-list/extend-book-list.controller.ts diff --git a/apps/json-api-server/src/app/resources/type-orm/controllers/extend-user/extend-user.controller.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/controllers/extend-user/extend-user.controller.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/controllers/extend-user/extend-user.controller.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/controllers/extend-user/extend-user.controller.ts diff --git a/apps/json-api-server/src/app/json-api/type-orm/type-orm/resources-type.module.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/resources-type.module.ts new file mode 100644 index 00000000..c3ff72e4 --- /dev/null +++ b/apps/json-api-server/src/app/json-api/type-orm/type-orm/resources-type.module.ts @@ -0,0 +1,65 @@ +import { Module, Type } from '@nestjs/common'; +import { JsonApiModule } from '@klerick/json-api-nestjs'; +import { TypeOrmJsonApiModule } from '@klerick/json-api-nestjs-typeorm'; +import { + Users, + Addresses, + Comments, + Roles, + BookList, + UsersAcl, + ArticleAcl, + CategoryAcl, + TagAcl, + PostAcl, + CommentAcl, + DocumentAcl, + UserProfileAcl, + ContextTestAcl, +} from '@nestjs-json-api/typeorm-database'; + +import { ExtendBookListController } from './controllers/extend-book-list/extend-book-list.controller'; +import { ExtendUserController } from './controllers/extend-user/extend-user.controller'; +import { ExampleService } from './service/example.service'; +import { wrapperJsonApiController } from '@klerick/acl-json-api-nestjs'; + +@Module({ + imports: [ + JsonApiModule.forRoot(TypeOrmJsonApiModule, { + entities: [ + Users, + Addresses, + Comments, + Roles, + BookList, + UsersAcl, + ArticleAcl, + CategoryAcl, + TagAcl, + PostAcl, + CommentAcl, + DocumentAcl, + UserProfileAcl, + ContextTestAcl, + ], + controllers: [ExtendBookListController, ExtendUserController], + providers: [ExampleService], + options: { + debug: true, + requiredSelectField: false, + operationUrl: 'operation', + }, + hooks: { + afterCreateController: (controllerClass: Type) => { + if ( + !controllerClass.name.startsWith('ContextTestAcl') && + controllerClass.name.indexOf('Acl') > -1 + ) { + wrapperJsonApiController(controllerClass); + } + }, + }, + }), + ], +}) +export class ResourcesTypeModule {} diff --git a/apps/json-api-server/src/app/resources/type-orm/service/atomic.interceptor.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/service/atomic.interceptor.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/service/atomic.interceptor.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/service/atomic.interceptor.ts diff --git a/apps/json-api-server/src/app/resources/type-orm/service/controller.interceptor.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/service/controller.interceptor.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/service/controller.interceptor.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/service/controller.interceptor.ts diff --git a/apps/json-api-server/src/app/resources/type-orm/service/example.pipe.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/service/example.pipe.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/service/example.pipe.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/service/example.pipe.ts diff --git a/apps/json-api-server/src/app/resources/type-orm/service/example.service.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/service/example.service.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/service/example.service.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/service/example.service.ts diff --git a/apps/json-api-server/src/app/resources/type-orm/service/guard.service.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/service/guard.service.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/service/guard.service.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/service/guard.service.ts diff --git a/apps/json-api-server/src/app/resources/type-orm/service/http-exception.filter.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/service/http-exception.filter.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/service/http-exception.filter.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/service/http-exception.filter.ts diff --git a/apps/json-api-server/src/app/resources/type-orm/service/method.interceptor.ts b/apps/json-api-server/src/app/json-api/type-orm/type-orm/service/method.interceptor.ts similarity index 100% rename from apps/json-api-server/src/app/resources/type-orm/service/method.interceptor.ts rename to apps/json-api-server/src/app/json-api/type-orm/type-orm/service/method.interceptor.ts diff --git a/apps/json-api-server/src/app/resources/micro-orm/resources-micro.module.ts b/apps/json-api-server/src/app/resources/micro-orm/resources-micro.module.ts deleted file mode 100644 index 10b2c045..00000000 --- a/apps/json-api-server/src/app/resources/micro-orm/resources-micro.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Module } from '@nestjs/common'; -import { JsonApiModule } from '@klerick/json-api-nestjs'; -import { MicroOrmJsonApiModule } from '@klerick/json-api-nestjs-microorm'; -import { - Users, - Addresses, - Comments, - Roles, - BookList, -} from '@nestjs-json-api/microorm-database'; - -import { ExtendBookListController } from './controllers/extend-book-list/extend-book-list.controller'; -import { ExtendUserController } from './controllers/extend-user/extend-user.controller'; -import { ExampleService } from './service/example.service'; - -@Module({ - imports: [ - JsonApiModule.forRoot(MicroOrmJsonApiModule, { - entities: [Users, Addresses, Comments, Roles, BookList], - controllers: [ExtendBookListController, ExtendUserController], - providers: [ExampleService], - options: { - debug: true, - requiredSelectField: false, - operationUrl: 'operation', - }, - }), - ], -}) -export class ResourcesMicroModule {} diff --git a/apps/json-api-server/src/app/resources/type-orm/resources-type.module.ts b/apps/json-api-server/src/app/resources/type-orm/resources-type.module.ts deleted file mode 100644 index a0852684..00000000 --- a/apps/json-api-server/src/app/resources/type-orm/resources-type.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Module } from '@nestjs/common'; -import { JsonApiModule } from '@klerick/json-api-nestjs'; -import { TypeOrmJsonApiModule } from '@klerick/json-api-nestjs-typeorm'; -import { - Users, - Addresses, - Comments, - Roles, - BookList, -} from '@nestjs-json-api/typeorm-database'; - -import { ExtendBookListController } from './controllers/extend-book-list/extend-book-list.controller'; -import { ExtendUserController } from './controllers/extend-user/extend-user.controller'; -import { ExampleService } from './service/example.service'; - -@Module({ - imports: [ - JsonApiModule.forRoot(TypeOrmJsonApiModule, { - entities: [Users, Addresses, Comments, Roles, BookList], - controllers: [ExtendBookListController, ExtendUserController], - providers: [ExampleService], - options: { - debug: true, - requiredSelectField: false, - operationUrl: 'operation', - }, - }), - ], -}) -export class ResourcesTypeModule {} diff --git a/apps/json-api-server/src/main.ts b/apps/json-api-server/src/main.ts index cfe15691..ffe7308c 100644 --- a/apps/json-api-server/src/main.ts +++ b/apps/json-api-server/src/main.ts @@ -3,16 +3,18 @@ * This is only a minimal backend to get started. */ -import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { WsAdapter } from '@nestjs/platform-ws'; +import { Logger as PinoLogger } from 'nestjs-pino'; import { AppModule } from './app/app.module'; import { NestExpressApplication } from '@nestjs/platform-express'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + const logger = app.get(PinoLogger) + app.useLogger(logger); app.useWebSocketAdapter(new WsAdapter(app)); const globalPrefix = 'api'; app.setGlobalPrefix(globalPrefix); @@ -35,7 +37,7 @@ async function bootstrap() { const port = process.env.PORT || 3000; await app.listen(port); - Logger.log( + logger.log( `🚀 Application is running on: http://localhost:${port}/${globalPrefix}` ); } diff --git a/apps/json-api-server/tsconfig.app.json b/apps/json-api-server/tsconfig.app.json index 9be508be..78679223 100644 --- a/apps/json-api-server/tsconfig.app.json +++ b/apps/json-api-server/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + "module": "nodenext", "types": ["node"], "experimentalDecorators": true, "emitDecoratorMetadata": true, @@ -11,7 +11,8 @@ "strictNullChecks": true, "noImplicitAny": true, "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "moduleResolution": "nodenext" }, "include": ["src/**/*.ts"] } diff --git a/apps/json-api-server/tsconfig.json b/apps/json-api-server/tsconfig.json index 9af9a764..50335f0c 100644 --- a/apps/json-api-server/tsconfig.json +++ b/apps/json-api-server/tsconfig.json @@ -34,6 +34,9 @@ ], "@klerick/nestjs-json-rpc-sdk/ngModule": [ "libs/json-rpc/nestjs-json-rpc-sdk/src/ngModule.ts" + ], + "@klerick/acl-json-api-nestjs": [ + "dist/libs/acl-permissions/nestjs-acl-permissions" ] } }, diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index 9518d9d6..00000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,42 +0,0 @@ -version: '3.8' -services: - postgres: - image: postgres:15.1-alpine - restart: always - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - ports: - - '5432:5432' - volumes: - - db:/var/lib/postgresql/data - pgadmin: - container_name: 'pgadmin' - image: 'dpage/pgadmin4:latest' - hostname: pgadmin - depends_on: - - 'postgres' - environment: - - PGADMIN_DEFAULT_PASSWORD=password - - PGADMIN_DEFAULT_EMAIL=pg.admin@email.com - volumes: - - pgadmin:/root/.pgadmin - ports: - - '8000:80' - db: - image: mysql:latest - restart: always - environment: - MYSQL_ROOT_PASSWORD: mysql - ports: - - '3306:3306' - volumes: - - mysql-db:/var/lib/mysql - -volumes: - db: - driver: local - mysql-db: - driver: local - pgadmin: - driver: local diff --git a/libs/acl-permissions/nestjs-acl-permissions/README.md b/libs/acl-permissions/nestjs-acl-permissions/README.md new file mode 100644 index 00000000..3ae24a6d --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/README.md @@ -0,0 +1,3556 @@ +

+ NPM Version + Package License + NPM Downloads + Commitizen friendly + Coverage Badge +

+ +# acl-json-api-nestjs + + +Type-safe, flexible Access Control List (ACL) module for NestJS with CASL integration and template-based rule materialization. + +**⚠️ Module Purpose:** + +This module was specifically designed to integrate with `@klerick/json-api-nestjs`, providing: +- ✅ **Automatic ACL setup** via `wrapperJsonApiController` hook +- ✅ **Transparent ORM-level filtering** for JSON:API operations + +**Can be used standalone** with any NestJS application: +- ⚙️ Manual setup required: apply `@AclController` decorator and `AclGuard` to controllers +- ✅ All features available: template materialization, field-level permissions, context/input interpolation + +## Features + +- **Two-stage materialization** - Static rules (context) vs dynamic rules (@input) +- **Guard-based authorization** - Fail-fast approach with AclGuard +- **CLS integration** - ExtendableAbility available in pipes/guards/services via contextStore +- **Template interpolation** - Use `${currentUserId}` (context) and `${@input.data}` (@input) in rules +- **Lazy evaluation** - Rules with @input are materialized only when needed + +## Installation + +```bash +npm install @klerick/nestjs-acl-permissions @casl/ability +``` + +**Recommended:** Install `nestjs-cls` for context store (provides AsyncLocalStorage-based storage): +```bash +npm install nestjs-cls +``` + +## Quick Start + +### 1. Define your RulesLoader + +```typescript +import { Injectable } from '@nestjs/common'; +import { AclRulesLoader, AclRule } from '@klerick/nestjs-acl-permissions'; + +@Injectable() +export class MyRulesLoaderService implements AclRulesLoader { + async loadRules(entity: any, action: string): Promise[]> { + return [ + { + action: 'getAll', + subject: 'Post', + fields: ['title', 'content'], // Only these fields allowed + }, + { + action: 'patchOne', + subject: 'Post', + conditions: { authorId: '${currentUserId}' }, // From context + }, + ]; + } + + async getContext(): Promise> { + // Return session data (e.g., current user) + return { + currentUserId: 123, + role: 'user', + }; + } + + async getHelpers(): Promise unknown>> { + return {}; // Optional helper functions + } +} +``` + +### 2. Register the module with Context Store + +**⚠️ IMPORTANT:** ACL module requires a `contextStore` that implements `AclContextStore` interface and uses `AsyncLocalStorage` internally. + +**📦 Recommended:** Use `nestjs-cls` - a ready-made solution: + +```bash +npm install nestjs-cls +``` + +```typescript +import { Module } from '@nestjs/common'; +import { AclPermissionsModule } from '@klerick/nestjs-acl-permissions'; +import { ClsModule, ClsService } from 'nestjs-cls'; + +@Module({ + imports: [ + // ClsModule - recommended context store implementation + // Uses AsyncLocalStorage for request-scoped data (no REQUEST scope needed!) + ClsModule.forRoot({ + global: true, // Make ClsService available everywhere + middleware: { + mount: true, // Mount middleware to initialize CLS context per-request + }, + }), + + // ACL module + AclPermissionsModule.forRoot({ + rulesLoader: MyRulesLoaderService, + contextStore: ClsService, // Pass any service that implements AclContextStore + onNoRules: 'deny', // deny | allow (default: 'deny') + defaultRules: [], // Optional fallback rules + }), + ], +}) +export class AppModule {} +``` + +**Why use a Context Store with AsyncLocalStorage?** + +- `AsyncLocalStorage` provides request-scoped data **without using `Scope.REQUEST`** +- Your services remain **SINGLETONS** (created once) and still access request-specific ACL ability +- No performance penalty from recreating providers on every request + +**Custom Implementation (if needed):** + +You can implement your own `contextStore`: + +```typescript +interface AclContextStore { + get(key: symbol | string): T | undefined; + set(key: symbol | string, value: T): void; +} + +// Your custom implementation using AsyncLocalStorage +@Injectable() +export class MyContextStore implements AclContextStore { + private storage = new AsyncLocalStorage>(); + + get(key: symbol | string): T | undefined { + return this.storage.getStore()?.get(key); + } + + set(key: symbol | string, value: T): void { + this.storage.getStore()?.set(key, value); + } + + // Middleware to initialize storage per-request + middleware(req, res, next) { + this.storage.run(new Map(), () => next()); + } +} +``` + +### 3. Apply ACL to controllers + +**Option A: Automatic (with `@klerick/json-api-nestjs`)** + +If you're using `@klerick/json-api-nestjs`, ACL is applied automatically via hook: + +```typescript +import { Module } from '@nestjs/common'; +import { JsonApiModule } from '@klerick/json-api-nestjs'; +import { MicroOrmJsonApiModule } from '@klerick/json-api-nestjs-microorm'; +import { wrapperJsonApiController } from '@klerick/nestjs-acl-permissions'; + +@Module({ + imports: [ + JsonApiModule.forRoot(MicroOrmJsonApiModule, { + entities: [User, Post, Comment], + hooks: { + afterCreateController: wrapperJsonApiController, // 🔥 Automatic ACL + }, + }), + ], +}) +export class ResourcesModule {} +``` + +The hook automatically applies `@AclController` and `@UseGuards(AclGuard)` to all JSON:API controllers that don't have the decorator yet. + +**Option B: Override per controller (with `@klerick/json-api-nestjs`)** + +If the hook is enabled, you can still override ACL settings for specific controllers by applying `@AclController` manually. **The hook will detect the existing decorator and skip it**, using your custom settings instead: + +```typescript +import { Controller } from '@nestjs/common'; +import { AclController } from '@klerick/nestjs-acl-permissions'; +import { JsonBaseController } from '@klerick/json-api-nestjs'; + +@AclController({ + subject: Post, + methods: { + getAll: true, // Enable ACL with global options + getOne: true, // Enable ACL with global options + patchOne: true, // Enable ACL with global options + deleteOne: false, // Disable ACL for this method + }, +}) +export class PostsController extends JsonBaseController {} +``` + +**Per-method options override:** + +You can override `onNoRules` and `defaultRules` for specific methods: + +```typescript +@AclController({ + subject: Post, + methods: { + getAll: true, // Uses global onNoRules and defaultRules + + getOne: false, // ACL completely disabled + + patchOne: { // Override options for this method only + onNoRules: 'allow', // Allow if no rules (ignores global 'deny') + defaultRules: [ // Fallback rules for this method + { + action: 'patchOne', + subject: 'Post', + conditions: { authorId: '${currentUserId}' }, + }, + ], + }, + + deleteOne: { // Strict mode for this method + onNoRules: 'deny', + defaultRules: [], // No fallback + }, + }, +}) +export class PostsController extends JsonBaseController {} +``` + +**Options priority:** +``` +Method-specific options > Global module options > Default ('deny') +``` + +**Option C: Standalone (without `@klerick/json-api-nestjs`)** + +You can use this module with regular NestJS controllers. Just apply `@AclController` decorator and `@UseGuards(AclGuard)`: + +```typescript +import { Controller, Get, Post, Patch, Delete, UseGuards } from '@nestjs/common'; +import { AclController, AclGuard } from '@klerick/nestjs-acl-permissions'; + +@AclController({ + subject: 'Post', // String subject + methods: { + findAll: true, // Your method names + findOne: true, + update: true, + remove: false, + }, +}) +@Controller('posts') +export class PostsController { + @Get() + findAll() { + // Your logic... + } + + @Get(':id') + findOne() { + // Your logic... + } + + @Patch(':id') + update() { + // Your logic... + } + + @Delete(':id') + remove() { + // Your logic... + } +} +``` + +**Note:** When using standalone mode, you'll need to manually handle ACL checks in your service layer using `ExtendAbility.updateWithInput()` for `@input` template materialization. + +### 4. Use ExtendAbility in services (optional) + +**⚠️ DO NOT use `Scope.REQUEST`!** The `ExtendAbility` provider is a **SINGLETON Proxy** that automatically retrieves the ability for the current request from contextStore. + +**For `@klerick/json-api-nestjs`:** ACL checks are handled automatically at the ORM level. You don't need to inject `ExtendAbility` in your services unless you have custom logic. + +**For standalone mode:** You need to manually inject and use `ExtendAbility`: + +```typescript +import { Injectable, Inject, ForbiddenException } from '@nestjs/common'; +import { ExtendAbility } from '@klerick/nestjs-acl-permissions'; +import { subject } from '@casl/ability'; + +@Injectable() +export class PostsService { + // Inject ExtendAbility like any other dependency + // This is a SINGLETON proxy - your service stays SINGLETON too! + @Inject(ExtendAbility) + private readonly ability!: ExtendAbility; + + async updatePost(id: number, data: UpdatePostDto) { + const post = await this.loadPost(id); + + // Update ability with entity data for @input templates + this.ability.updateWithInput(post); + + // Check access with materialized rules (context + @input) + if (!this.ability.can('patchOne', subject('Post', post))) { + throw new ForbiddenException('Cannot update this post'); + } + + return this.savePost(post, data); + } + + async deletePost(id: number) { + const post = await this.loadPost(id); + + // Update ability with entity data + this.ability.updateWithInput(post); + + // Check deletion access + if (!this.ability.can('deleteOne', subject('Post', post))) { + throw new ForbiddenException('Cannot delete this post'); + } + + return this.removePost(post); + } +} +``` + +**How it works:** + +1. `ExtendAbility` is a **Proxy** (not a real instance) +2. When you call `this.ability.can()`, the proxy retrieves the actual ability from contextStore +3. contextStore (via `AsyncLocalStorage`) automatically returns data for the **current request** +4. No `Scope.REQUEST` needed - your service is still a **SINGLETON** +5. `updateWithInput()` materializes rules with `@input` data from the entity + +**Two-stage materialization:** +- **Guard level**: Rules materialized with `context` only (fast check) +- **Service level**: Call `updateWithInput()` to materialize rules with `@input` data (full check) + +--- + +## Template Interpolation System + +The ACL module uses a powerful template interpolation system that allows you to embed dynamic values in your rules using `${...}` syntax. This section explains how it works in detail. + +### Template Syntax + +Templates use JavaScript-like expressions inside `${}`: + +```typescript +// Rule with templates: +{ + action: 'getAll', + subject: 'Post', + conditions: { + authorId: '${currentUserId}', // Context variable + status: '${@input.status}', // Input variable + createdAt: { $gt: '${yesterday()}' } // Helper function + } +} + +// After materialization: +{ + action: 'getAll', + subject: 'Post', + conditions: { + authorId: 123, // Value from context + status: 'published', // Value from input + createdAt: { $gt: '2025-01-10T00:00:00.000Z' } // Result of helper + } +} +``` + +**Important:** Templates are **strings** that contain `${...}` expressions. The interpolation happens during rule materialization. + +### Three Types of Variables + +#### 1. Context Variables - `${varName}` + +**Available:** Always (materialized at Guard level) +**Source:** `AclRulesLoader.getContext()` +**Use case:** Session data, current user info, global settings + +```typescript +// In your RulesLoader: +async getContext(): Promise> { + return { + currentUserId: 123, + currentUser: { + id: 123, + role: 'moderator', + departmentId: 5 + }, + tenantId: 'acme-corp' + }; +} + +// In rules: +{ + conditions: { + authorId: '${currentUserId}', // Simple variable + 'author.role': '${currentUser.role}', // Nested access + departmentId: '${currentUser.departmentId}', // Nested property + tenant: '${tenantId}' // Top-level variable + } +} + +// After materialization: +{ + conditions: { + authorId: 123, + 'author.role': 'moderator', + departmentId: 5, + tenant: 'acme-corp' + } +} +``` + +**Nested access:** + +```typescript +// Context: +{ + currentUser: { + profile: { + settings: { + theme: 'dark' + } + } + } +} + +// Rule: +{ conditions: { theme: '${currentUser.profile.settings.theme}' } } +// → { conditions: { theme: 'dark' } } +``` + +#### 2. Input Variables - `${@input.field}` + +**Available:** Only after `updateWithInput()` (Service level) +**Source:** Entity data passed to `updateWithInput(entity)` +**Use case:** Entity-specific conditions, field-level validation + +```typescript +// In service (after fetching entity): +const post = await this.loadPost(id); // { id: 5, authorId: 123, status: 'draft' } +this.ability.updateWithInput(post); // Materialize with entity data + +// Rules with @input: +{ + conditions: { + authorId: '${@input.authorId}', // Field from entity + status: '${@input.status}', // Another field + 'tags': { $size: '${@input.tags.length}' } // Array property + } +} + +// After updateWithInput: +{ + conditions: { + authorId: 123, // From post.authorId + status: 'draft', // From post.status + 'tags': { $size: 3 } // From post.tags.length + } +} +``` + +**Array operations with `.map()` syntax:** + +```typescript +// Entity: +{ + id: 5, + tags: [ + { id: 1, name: 'tech' }, + { id: 2, name: 'news' }, + { id: 3, name: 'tutorial' } + ] +} + +// Rule - extract all IDs: +{ + conditions: { + 'tags.id': { $in: '${@input.tags.map(i => i.id)}' } // Extract all ids + } +} + +// After materialization: +{ + conditions: { + 'tags.id': { $in: [1, 2, 3] } // Array of extracted values + } +} +``` + +**Common patterns:** + +```typescript +// Check if array contains value +{ coAuthorIds: { $in: ['${currentUserId}'] } } + +// Extract IDs from relationship array +{ 'posts.id': { $in: '${@input.posts.map(i => i.id)}' } } + +// Array size validation +{ tags: { $size: '${@input.tags.length}' } } + +// All items must match condition +{ comments: { $all: { authorId: '${currentUserId}' } } } +``` + +#### 3. `__current` Variables - `${@input.__current.field}` + +**Available:** Only in `patchOne` and `patchRelationship` +**Source:** OLD entity values (before update) +**Use case:** Compare old vs new values, validate transitions + +```typescript +// patchOne scenario: +// OLD entity (from DB): { id: 5, status: 'draft', coAuthorIds: [1, 2, 3] } +// NEW data (from request): { status: 'review', coAuthorIds: [2, 3, 4] } + +// Entity passed to updateWithInput: +{ + id: 5, + status: 'review', // NEW value at root + coAuthorIds: [2, 3, 4], // NEW value at root + __current: { + id: 5, + status: 'draft', // OLD value in __current + coAuthorIds: [1, 2, 3] // OLD value in __current + } +} + +// Rules with __current: +{ + conditions: { + // OLD status must be draft + '__current.status': 'draft', + + // NEW status must be review or published + 'status': { $in: ['review', 'published'] }, + + // NEW array must include all OLD items (can only add, not remove) + 'coAuthorIds': { $all: '${@input.__current.coAuthorIds}' } + } +} + +// After materialization: +{ + conditions: { + '__current.status': 'draft', + 'status': { $in: ['review', 'published'] }, + 'coAuthorIds': { $all: [1, 2, 3] } // Must contain old IDs + } +} +``` + +**Use cases:** + +1. **State transitions:** "Can change status from draft to review, but not to published" +2. **Add-only updates:** "Can add items to array but cannot remove existing ones" +3. **Conditional removal:** "Can remove only yourself from coAuthors" +4. **Value increase:** "Can increase price but not decrease it" + +### Helper Functions - `${helperName(arg1, arg2)}` + +**Available:** Always +**Source:** `AclRulesLoader.getHelpers()` +**Use case:** Complex calculations, reusable logic + +```typescript +// In your RulesLoader: +async getHelpers(): Promise unknown>> { + return { + // Helper: Remove userId from array + removeMyselfOnly: (oldArray: number[], userId: number): number[] => { + return oldArray.filter(id => id !== userId); + }, + + // Helper: Check if date is in past + isInPast: (dateStr: string): boolean => { + return new Date(dateStr) < new Date(); + }, + + // Helper: Calculate yesterday + yesterday: (): string => { + const date = new Date(); + date.setDate(date.getDate() - 1); + return date.toISOString(); + }, + + // Helper: Extract unique IDs + uniqueIds: (items: Array<{ id: number }>): number[] => { + return [...new Set(items.map(i => i.id))]; + } + }; +} + +// In rules: +{ + action: 'patchOne', + subject: 'Article', + conditions: { + // CoAuthor can remove only themselves + 'coAuthorIds': { + $all: '${removeMyselfOnly(@input.__current.coAuthorIds, currentUser.id)}', + $size: '${@input.__current.coAuthorIds.length - 1}' + }, + + // Must be created in the past + '__current.createdAt': { $lt: '${yesterday()}' }, + + // Check if already published + 'isPublished': '${isInPast(@input.publishedAt)}' + } +} +``` + +**Helper function arguments:** + +You can pass three types of values to helpers: +1. **Context variables:** `${helper(currentUserId)}` +2. **Input variables:** `${helper(@input.tags)}` +3. **Literals:** `${helper('draft', 5, true)}` + +**Advanced example:** + +```typescript +// Helper: +getHelpers() { + return { + // Check if user is removing only themselves from array + isSelfRemovalOnly: ( + oldArray: number[], + newArray: number[], + userId: number + ): boolean => { + const removed = oldArray.filter(id => !newArray.includes(id)); + return removed.length === 1 && removed[0] === userId; + } + }; +} + +// Rule: +{ + conditions: { + // Custom validation using helper + 'valid': '${isSelfRemovalOnly(@input.__current.coAuthorIds, @input.coAuthorIds, currentUser.id)}' + } +} +``` + +### Two-Stage Materialization + +Rules are materialized in **two stages** for performance: + +#### Stage 1: Guard Level (Context Only) + +**When:** Request enters AclGuard +**Available variables:** Context variables + Helper functions +**Not available:** `@input` variables + +```typescript +// Original rule: +{ + action: 'patchOne', + subject: 'Post', + conditions: { + departmentId: '${currentUser.departmentId}', // ✅ Available (context) + authorId: '${@input.authorId}' // ❌ Not available yet + } +} + +// After Stage 1 (Guard): +{ + conditions: { + departmentId: 5, // ✅ Materialized + authorId: '${@input.authorId}' // ❌ Still template + } +} +``` + +**Guard checks:** `can('patchOne', 'Post')` +- If rule has only context variables → fully materialized → can evaluate +- If rule has `@input` variables → partially materialized → deferred until Stage 2 + +#### Stage 2: Service Level (Context + Input) + +**When:** `updateWithInput(entity)` is called +**Available variables:** All (Context + Input + Helpers) + +```typescript +// After Stage 2 (updateWithInput): +{ + conditions: { + departmentId: 5, // ✅ From Stage 1 + authorId: 123 // ✅ Materialized at Stage 2 + } +} +``` + +**Service checks:** `can('patchOne', subject('Post', post))` +- All templates materialized → full validation + +**Flow example:** + +```typescript +// 1. Request enters Guard +// → Rules materialized with context (Stage 1) +// → Check: can('patchOne', 'Post') → allowed + +// 2. Controller calls service +const post = await this.ormService.getOne(id); // Fetch entity + +// 3. Service updates ability +this.ability.updateWithInput(post); // Stage 2: materialize with entity data + +// 4. Service checks with full data +if (!this.ability.can('patchOne', subject('Post', post))) { + throw new ForbiddenException(); +} +``` + +### Strict Mode (Error Handling) + +**Default:** `strictInterpolation: true` (enabled) + +When a template references an **undefined variable**, the behavior depends on strict mode: + +#### Strict Mode Enabled (default) + +**Throws error immediately:** + +```typescript +// Configuration: +AclPermissionsModule.forRoot({ + rulesLoader: MyRulesLoader, + contextStore: ClsService, + strictInterpolation: true, // Default +}) + +// Rule with typo: +{ + conditions: { + authorId: '${@input.athourId}' // Typo: 'athourId' instead of 'authorId' + } +} + +// Error when updateWithInput is called: +// ReferenceError: Property 'input.athourId' is not defined in strict mode +// Available variables: input, currentUserId, currentUser, ... +``` + +**Benefits:** +- ✅ Catch typos and missing fields early +- ✅ Fail-fast approach +- ✅ Clear error messages + +**Recommended for:** Production environments + +#### Strict Mode Disabled + +**Logs warning, treats undefined as `null`:** + +```typescript +// Configuration: +AclPermissionsModule.forRoot({ + rulesLoader: MyRulesLoader, + contextStore: ClsService, + strictInterpolation: false, // Disable strict mode +}) + +// Rule with undefined variable: +{ + conditions: { + authorId: '${@input.athourId}' // Typo + } +} + +// After materialization: +{ + conditions: { + authorId: null // Undefined → null + } +} + +// Warning in logs: +// [WARN] Failed to materialize rules: Cannot read property 'athourId' of undefined. +// Available variables: input, currentUserId, currentUser, ... +``` + +**Use case:** Development, debugging, or when you want lenient behavior + +### Nested Object Access + +Access nested properties using dot notation: + +```typescript +// Context: +{ + currentUser: { + profile: { + department: { + id: 5, + name: 'Engineering', + location: { + city: 'New York', + country: 'USA' + } + } + }, + permissions: ['read', 'write'] + } +} + +// Rules with nested access: +{ + conditions: { + // Simple nested + 'departmentId': '${currentUser.profile.department.id}', + + // Deep nested + 'location.city': '${currentUser.profile.department.location.city}', + + // Array element + 'permission': '${currentUser.permissions[0]}', // 'read' + + // Combining nested + array extraction + 'user.permissions': { $in: '${currentUser.permissions}' } + } +} + +// After materialization: +{ + conditions: { + 'departmentId': 5, + 'location.city': 'New York', + 'permission': 'read', + 'user.permissions': { $in: ['read', 'write'] } + } +} +``` + +**With `@input`:** + +```typescript +// Entity: +{ + id: 5, + author: { + id: 123, + profile: { + department: { + id: 10, + name: 'Sales' + } + } + }, + tags: [ + { id: 1, category: { name: 'Tech' } }, + { id: 2, category: { name: 'News' } } + ] +} + +// Rules: +{ + conditions: { + // Nested object + 'authorDepartment': '${@input.author.profile.department.id}', + + // Extract from nested arrays + 'categories': { $in: '${@input.tags.map(i => i.category.name)}' } + } +} + +// After materialization: +{ + conditions: { + 'authorDepartment': 10, + 'categories': { $in: ['Tech', 'News'] } + } +} +``` + +### Array Extraction with `.map()` + +Extract properties from all items in an array using `.map()` syntax: + +```typescript +// Entity: +{ + posts: [ + { id: 1, title: 'Post A', authorId: 123 }, + { id: 2, title: 'Post B', authorId: 123 }, + { id: 3, title: 'Post C', authorId: 456 } + ] +} + +// Extract all IDs: +'${@input.posts.map(i => i.id)}' // → [1, 2, 3] + +// Extract all authorIds: +'${@input.posts.map(i => i.authorId)}' // → [123, 123, 456] + +// Extract all titles: +'${@input.posts.map(i => i.title)}' // → ['Post A', 'Post B', 'Post C'] + +// Use in conditions: +{ + conditions: { + // Check if specific post ID exists + 'posts.id': { $in: '${@input.posts.map(i => i.id)}' }, + + // All posts must be by current user + 'posts': { + $all: { authorId: '${currentUserId}' } + } + } +} +``` + +**Nested extraction:** + +```typescript +// Entity with nested arrays: +{ + posts: [ + { + id: 1, + tags: [ + { id: 10, name: 'tech' }, + { id: 20, name: 'news' } + ] + }, + { + id: 2, + tags: [ + { id: 30, name: 'tutorial' } + ] + } + ] +} + +// Extract all tag IDs from all posts: +// ❌ This doesn't work: '${@input.posts.map(p => p.tags.map(t => t.id))}' // Returns nested arrays +// ✅ Use helper function with flatMap instead: + +// Helper: +getHelpers() { + return { + flattenTagIds: (posts: Array<{ tags: Array<{ id: number }> }>): number[] => { + return posts.flatMap(p => p.tags.map(t => t.id)); + } + }; +} + +// Rule: +{ conditions: { 'tagIds': { $in: '${flattenTagIds(@input.posts)}' } } } +// → { 'tagIds': { $in: [10, 20, 30] } } +``` + +### Type Handling + +The interpolation system handles different types correctly: + +```typescript +// String: +'${@input.name}' // → "John Doe" + +// Number: +'${@input.age}' // → 25 + +// Boolean: +'${@input.isActive}' // → true + +// null: +'${@input.deletedAt}' // → null + +// undefined (strict mode off): +'${@input.missing}' // → null + +// Array: +'${@input.tags}' // → [1, 2, 3] + +// Object: +'${@input.metadata}' // → { "key": "value" } + +// Date: +'${@input.createdAt}' // → "2025-01-11T00:00:00.000Z" (ISO string) + +// Nested: +'${@input.user.profile.bio}' // → "Software engineer" + +// Array of objects: +'${@input.posts.map(i => i.id)}' // → [1, 2, 3] +``` + +### Edge Cases and Limitations + +#### 1. **Escaping `${` in string values** + +If your data contains literal `${`, it won't be treated as a template: + +```typescript +// Context with literal ${}: +{ + message: 'Use ${variable} syntax' // This is data, not a template +} + +// Rule: +{ conditions: { msg: '${message}' } } + +// After materialization: +{ conditions: { msg: 'Use ${variable} syntax' } } // ✅ Works fine +``` + +Templates are only evaluated in **rule definitions**, not in data values. + +#### 2. **Circular references** + +Circular references in context/input will cause errors: + +```typescript +// ❌ Bad: +const user = { id: 123 }; +user.self = user; // Circular reference + +this.ability.updateWithInput(user); // Error: Converting circular structure to JSON +``` + +**Solution:** Don't pass circular structures to `updateWithInput()` + +#### 3. **Nested `.map()` returns nested arrays** + +```typescript +// ✅ Works - single level: +'${@input.posts.map(i => i.id)}' // Extract IDs from posts → [1, 2, 3] + +// ❌ Doesn't work - nested arrays: +'${@input.posts.map(p => p.tags.map(t => t.id))}' // Returns [[1,2], [3,4]] instead of [1,2,3,4] + +// ✅ Use helper function with flatMap: +getHelpers() { + return { + extractNestedIds: (posts) => posts.flatMap(p => p.tags.map(t => t.id)) + }; +} +{ conditions: { ids: '${extractNestedIds(@input.posts)}' } } +``` + +#### 4. **Undefined vs null** + +- `undefined` properties are converted to `null` in JSON (JSON spec) +- In strict mode, accessing undefined property throws error **before** conversion + +```typescript +// Entity: +{ id: 5, name: 'John' } // No 'age' property + +// Rule: +{ conditions: { age: '${@input.age}' } } + +// Strict mode ON: ReferenceError (property not defined) +// Strict mode OFF: { age: null } +``` + +#### 5. **Helper functions must be synchronous** + +```typescript +// ❌ Bad: Async helper +getHelpers() { + return { + fetchUser: async (id) => { // ❌ Async not supported + return await db.getUser(id); + } + }; +} + +// ✅ Good: Sync helper +getHelpers() { + return { + calculateAge: (birthDate: string): number => { + return new Date().getFullYear() - new Date(birthDate).getFullYear(); + } + }; +} +``` + +**Why?** Rule materialization happens synchronously for performance. + +#### 6. **Template expressions must be valid JavaScript** + +```typescript +// ✅ Valid: +'${@input.age > 18}' // Boolean expression +'${@input.tags.length}' // Property access +'${helper(@input.value, "test", 123)}' // Function call + +// ❌ Invalid: +'${@input.age > 18 ? "adult" : "minor"}' // Ternary not supported (use helper) +'${const x = 5; return x * 2;}' // Statements not supported +``` + +### Common Patterns + +#### Pattern 1: Owner-only access + +```typescript +{ + action: 'patchOne', + subject: 'Post', + conditions: { + authorId: '${@input.authorId}', // Entity must belong to user + 'author.id': '${currentUserId}' // Alternative: nested check + } +} +``` + +#### Pattern 2: Role-based with field restrictions + +```typescript +// Context: +{ currentUser: { role: 'moderator' } } + +// Rules: +[ + { + action: 'getAll', + subject: 'User', + conditions: { role: 'user' }, // Can see only regular users + }, + { + action: 'getAll', + subject: 'User', + conditions: { id: '${currentUser.id}' }, // Can see own profile + fields: ['*'] // All fields for own profile + } +] +``` + +#### Pattern 3: State machine transitions + +```typescript +{ + action: 'patchOne', + subject: 'Order', + conditions: { + '__current.status': 'pending', // OLD status + 'status': { $in: ['processing', 'cancelled'] } // NEW status (allowed transitions) + } +} +``` + +#### Pattern 4: Array manipulation with helpers + +```typescript +// Helper: +getHelpers() { + return { + canRemoveOnly: (oldArray: number[], newArray: number[], userId: number): boolean => { + const removed = oldArray.filter(id => !newArray.includes(id)); + const added = newArray.filter(id => !oldArray.includes(id)); + return added.length === 0 && removed.length === 1 && removed[0] === userId; + } + }; +} + +// Rule: CoAuthor can only remove themselves +{ + conditions: { + '__current.coAuthorIds': { $in: ['${currentUserId}'] }, // Was coauthor + 'valid': '${canRemoveOnly(@input.__current.coAuthorIds, @input.coAuthorIds, currentUserId)}' + } +} +``` + +--- + +## API Reference + +### ExtendAbility + +The `ExtendAbility` class extends CASL's `PureAbility` and provides additional features for template materialization and query extraction. + +**Injection:** +```typescript +import { Injectable, Inject } from '@nestjs/common'; +import { ExtendAbility } from '@klerick/nestjs-acl-permissions'; + +@Injectable() +export class MyService { + @Inject(ExtendAbility) + private readonly ability!: ExtendAbility; +} +``` + +#### Methods + +##### `updateWithInput(input: AclInputData): void` + +Re-materializes ALL rules with `@input` data. This is the **second stage** of materialization. + +```typescript +// First stage (in Guard): rules materialized with context only +// ability.can('patchOne', 'Post') // Uses ${currentUserId} + +// Second stage (in Service): re-materialize with @input +this.ability.updateWithInput(entity); +// Now rules with ${@input.userId} are also materialized +``` + +**Parameters:** +- `input: AclInputData` - Any object with data for `${@input.*}` templates + +**Example:** +```typescript +const post = await this.getPost(id); +this.ability.updateWithInput(post); // Materialize with post data + +// Now you can use rules like: +// { conditions: { authorId: '${@input.authorId}' } } +``` + +--- + +##### `can(action: string, subject: any, field?: string): boolean` + +Check if action is allowed on subject. This is the native CASL method. + +**Parameters:** +- `action: string` - Action name (e.g., 'getAll', 'patchOne') +- `subject: any` - Subject to check (entity class, instance, or string) +- `field?: string` - Optional field name for field-level checks + +**Returns:** `boolean` - `true` if allowed, `false` otherwise + +**Examples:** +```typescript +import { subject } from '@casl/ability'; + +// Action-level check +if (this.ability.can('getAll', 'Post')) { + // Allowed to get all posts +} + +// Entity-level check (with instance) +const post = await this.getPost(id); +if (this.ability.can('patchOne', subject('Post', post))) { + // Allowed to patch THIS specific post +} + +// Field-level check +if (this.ability.can('getAll', 'Post', 'title')) { + // Allowed to read 'title' field +} +``` + +**⚠️ Important:** +- For entity instances, use `subject('EntityName', instance)` helper from CASL +- Field-level checks require `fields` in rules +- Always call `updateWithInput()` before checking if you need `@input` data + +--- + +##### `hasConditions: boolean` + +Getter that returns `true` if any rule contains `conditions`. + +**Use case:** Optimization - skip query modifications if no conditions exist. + +```typescript +if (this.ability.hasConditions) { + // Fetch data with ACL query filtering + const aclQuery = this.ability.getQueryObject(); + // ... +} else { + // Fast path - fetch without ACL filtering +} +``` + +--- + +##### `hasFields: boolean` + +Getter that returns `true` if any rule contains `fields`. + +**Use case:** Optimization - skip field filtering if no field restrictions exist. + +```typescript +if (this.ability.hasFields) { + // Need to filter fields +} else { + // Fast path - no field filtering needed +} +``` + +--- + +##### `hasConditionsAndFields(): boolean` + +Returns `true` if any rule has BOTH `conditions` AND `fields`. + +**Use case:** Determine filtering strategy. + +```typescript +if (this.ability.hasConditionsAndFields()) { + // Need both query filtering AND field filtering +} +``` + +--- + +##### `getQueryObject(): { fields?, include?, rulesForQuery? }` + +Extracts query data from ACL conditions. Used internally by ORM Proxy. + +**Returns:** +```typescript +{ + fields?: { + target?: string[]; // Entity fields to fetch + [relation: string]?: string[]; // Relationship fields to fetch + }; + include?: string[]; // Relations to include (JOIN) + rulesForQuery?: Record; // Knex-compatible query object +} +``` + +**About `rulesForQuery`:** +- Returns a **Knex-compatible query object** (not raw MongoDB) +- Can be used directly with MikroORM's query builder +- **For `@klerick/json-api-nestjs`**: Handled automatically by ORM Proxy, you don't need to use it +- **For standalone**: Can be used to build filtered queries manually + +**Example:** +```typescript +const aclData = this.ability.getQueryObject(); + +// Rules: [{ conditions: { authorId: 123, 'profile.isPublic': true } }] +// Returns: +// { +// fields: { target: ['authorId'], profile: ['isPublic'] }, +// include: ['profile'], +// rulesForQuery: { authorId: 123, profile: { isPublic: true } } +// } + +// Usage with MikroORM (standalone mode): +const qb = em.createQueryBuilder(Post); +if (aclData.rulesForQuery) { + qb.where(aclData.rulesForQuery); +} +``` + +**Use case:** Used by ORM Proxy to automatically filter queries with ACL conditions. If you're using `@klerick/json-api-nestjs`, this is handled transparently - you typically don't need to call this manually. + +--- + +##### `get action(): string` + +Returns the current action name. + +```typescript +console.log(this.ability.action); // 'getAll' +``` + +--- + +##### `get subject(): string` + +Returns the current subject name. + +```typescript +console.log(this.ability.subject); // 'Post' +``` + +--- + +##### `get rules(): RawRuleFrom[]` + +Returns the original rules array (before materialization). + +**Use case:** Debugging, logging, or custom logic. + +```typescript +console.log(this.ability.rules); +// [ +// { action: 'getAll', subject: 'Post', conditions: { authorId: '${currentUserId}' } } +// ] +``` + +--- + +##### `get context(): Record` + +Returns the context object used for materialization. + +```typescript +console.log(this.ability.context); +// { currentUserId: 123, role: 'admin' } +``` + +--- + +##### `get helpers(): Record` + +Returns the helper functions object. + +```typescript +console.log(this.ability.helpers); +// { extractIds: [Function], isSameDepartment: [Function] } +``` + +--- + +### CASL Methods + +Since `ExtendAbility` extends `PureAbility`, you also have access to all CASL methods: + +- `cannot(action, subject, field?)` - Inverse of `can()` +- `relevantRuleFor(action, subject, field?)` - Get relevant rule +- `rulesFor(action, subject)` - Get all rules for action/subject + +See [CASL documentation](https://casl.js.org/v6/en/api/casl-ability) for full API. + +--- + +## Integration with @klerick/json-api-nestjs + +### Automatic Protection via Hook + +The ACL module integrates seamlessly with `@klerick/json-api-nestjs` via the hook system: + +```typescript +import { Module } from '@nestjs/common'; +import { JsonApiModule } from '@klerick/json-api-nestjs'; +import { MicroOrmJsonApiModule } from '@klerick/json-api-nestjs-microorm'; +import { AclPermissionsModule, wrapperJsonApiController } from '@klerick/nestjs-acl-permissions'; +import { ClsModule, ClsService } from 'nestjs-cls'; + +@Module({ + imports: [ + // CLS for storing ExtendAbility + ClsModule.forRoot({ global: true, middleware: { mount: true } }), + + // ACL module + AclPermissionsModule.forRoot({ + rulesLoader: MyRulesLoaderService, + contextStore: ClsService, + onNoRules: 'deny', // Default behavior + }), + + // JSON API with ACL hook + JsonApiModule.forRoot(MicroOrmJsonApiModule, { + entities: [User, Post, Comment], + hooks: { + afterCreateController: wrapperJsonApiController, // 🔥 ACL integration + }, + }), + ], +}) +export class ResourcesModule {} +``` + +**What happens:** + +1. JSON API creates controllers for each entity (`UserJsonApiController`, `PostJsonApiController`, etc.) +2. `wrapperJsonApiController` hook automatically: + - Applies `@AclController` metadata with entity as subject + - Applies `@UseGuards(AclGuard)` to protect all methods + - Wraps ORM service methods with ACL filtering proxies +3. All JSON:API endpoints are now ACL-protected automatically with transparent ORM-level filtering + +### ORM-Level Filtering + +**Key Feature:** ACL filtering happens at the ORM level, not in pipes or interceptors. + +```typescript +// When user calls: GET /posts +// +// 1. AclGuard checks: can('getAll', 'Post') +// 2. If allowed, ExtendAbility is stored in CLS +// 3. Controller calls ormService.getAll(query) +// 4. ORM Proxy intercepts the call: +// - Extracts ACL conditions via ability.getQueryObject() +// - Merges user query with ACL query (fields, includes, conditions) +// - Fetches data with ACL filtering applied +// - Filters fields per-item if needed (field-level permissions) +// - Returns filtered result +``` + +**Benefits:** + +- ✅ **Transparent** - Controllers don't need to know about ACL +- ✅ **Performant** - Database-level filtering (WHERE clauses) +- ✅ **Secure** - Field-level filtering after fetch if needed +- ✅ **Complete** - Handles all JSON:API operations (CRUD + relationships) + +### Important: onNoRules Behavior + +**⚠️ Default Behavior:** If `onNoRules: 'deny'` (default) and no rules are found, ACL will **block access with 403 Forbidden**. + +```typescript +// Configuration: +AclPermissionsModule.forRoot({ + rulesLoader: MyRulesLoader, + contextStore: ClsService, + onNoRules: 'deny', // Default: deny if no rules + defaultRules: [], // Default: no fallback rules +}) + +// If MyRulesLoader returns empty array: +async loadRules(subject, action) { + return []; // No rules! +} + +// Result: 403 Forbidden +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow access", +// "path": [] +// }] +// } +``` + +**Override per controller/method:** + +```typescript +@AclController({ + subject: Post, + methods: { + getAll: { + onNoRules: 'allow', // Override: allow if no rules for this method + }, + patchOne: true, // Use global onNoRules: 'deny' + }, +}) +export class PostsController extends JsonBaseController {} +``` + +**Use cases:** + +- **Strict mode** (`onNoRules: 'deny'`): Require explicit rules for every action +- **Development mode** (`onNoRules: 'allow'`): Allow access while rules are being developed +- **Per-method override**: Strict for mutations, relaxed for reads + +**What happens with `onNoRules: 'allow'`:** + +```typescript +AclPermissionsModule.forRoot({ + rulesLoader: MyRulesLoader, + contextStore: ClsService, + onNoRules: 'allow', // Allow access if no rules + log warning +}) + +// If MyRulesLoader returns empty array: +async loadRules(subject, action) { + return []; // No rules! +} + +// Result: Access ALLOWED + Warning in logs +// ⚠️ Warning: No ACL rules found for action 'getAll' on subject 'Post'. Access allowed by onNoRules: 'allow' +``` + +### JSON:API Actions Reference + +The module uses JSON:API method names as actions. Here's the complete mapping: + +| HTTP Method | Path | Action | Description | +|-------------|------|--------|-------------| +| GET | `/posts` | `getAll` | List all posts | +| GET | `/posts/:id` | `getOne` | Get single post | +| POST | `/posts` | `postOne` | Create new post | +| PATCH | `/posts/:id` | `patchOne` | Update post | +| DELETE | `/posts/:id` | `deleteOne` | Delete post | +| GET | `/posts/:id/relationships/:relName` | `getRelationship` | Get relationship data | +| POST | `/posts/:id/relationships/:relName` | `postRelationship` | Add to relationship | +| PATCH | `/posts/:id/relationships/:relName` | `patchRelationship` | Replace relationship | +| DELETE | `/posts/:id/relationships/:relName` | `deleteRelationship` | Remove from relationship | + +**Example rules for all actions:** + +```typescript +@Injectable() +export class MyRulesLoaderService implements AclRulesLoader { + async loadRules(entity: any, action: string): Promise[]> { + if (entity === Post) { + return [ + // Read access for all posts + { + action: 'getAll', + subject: 'Post', + fields: ['id', 'title', 'content', 'createdAt'], // Field-level restrictions + }, + // Read single post + { + action: 'getOne', + subject: 'Post', + fields: ['id', 'title', 'content', 'createdAt', 'authorId'], + }, + // Create new post + { + action: 'postOne', + subject: 'Post', + }, + // Update: only author can update + { + action: 'patchOne', + subject: 'Post', + conditions: { authorId: '${currentUserId}' }, // Entity-level condition + fields: ['title', 'content'], // Can only update these fields + }, + // Delete: only author can delete + { + action: 'deleteOne', + subject: 'Post', + conditions: { authorId: '${currentUserId}' }, + }, + // Relationship access + { + action: 'getRelationship', + subject: 'Post', + fields: ['author', 'comments'], // Can only access these relationships + }, + { + action: 'postRelationship', + subject: 'Post', + conditions: { authorId: '${currentUserId}' }, + fields: ['comments'], // Can only add comments + }, + { + action: 'patchRelationship', + subject: 'Post', + conditions: { authorId: '${currentUserId}' }, + fields: ['tags'], // Can only replace tags + }, + { + action: 'deleteRelationship', + subject: 'Post', + conditions: { authorId: '${currentUserId}' }, + fields: ['tags'], // Can only remove tags + }, + ]; + } + + return []; // No rules for other entities + } + + async getContext() { + return { + currentUserId: this.request.user?.id, + role: this.request.user?.role, + }; + } +} +``` + +--- + +## How ACL Works for Each Method + +### getAll - List All Entities + +**Flow:** + +```typescript +GET /posts +↓ +1. AclGuard checks: can('getAll', 'Post') +2. ORM Proxy intercepts ormService.getAll(query) +3. Prepare ACL query: + - Extract conditions from ability.getQueryObject() + - Extract field restrictions from ability.getQueryObject() + - Merge user query with ACL query +4. Validate: no __current templates (not supported for getAll) +5. Execute query with ACL filtering (WHERE clauses) +6. Post-process results: + - For each item: check field-level permissions + - Build fieldRestrictions array for items with hidden fields + - Transform to JSON:API format +7. Return: { meta: { fieldRestrictions }, data, included } +``` + +**Three ACL Scenarios:** + +**1. No conditions, all fields (admin)** + +```typescript +// Rule: +{ + action: 'getAll', + subject: 'UserProfile', + // No conditions = all records + // No fields = all fields visible +} + +// Result: All profiles with all fields +// GET /user-profiles +// => [ +// { id: 1, firstName: 'John', salary: 5000, role: 'admin', ... }, +// { id: 2, firstName: 'Jane', salary: 6000, role: 'moderator', ... } +// ] +``` + +**2. No conditions, limited fields (moderator)** + +```typescript +// Rule: +{ + action: 'getAll', + subject: 'UserProfile', + fields: ['id', 'firstName', 'lastName', 'avatar', 'phone'], // Only these fields +} + +// Result: All profiles but some fields hidden +// GET /user-profiles +// => [ +// { id: 1, firstName: 'John', lastName: 'Doe', avatar: '...', phone: '...' }, +// // salary and role are REMOVED from response +// ] +// meta: { +// fieldRestrictions: [ +// { id: 1, fields: ['salary', 'role'] }, +// { id: 2, fields: ['salary', 'role'] } +// ] +// } +``` + +**3. With conditions, per-item field restrictions (user)** + +```typescript +// Rules: +[ + { + action: 'getAll', + subject: 'UserProfile', + conditions: { isPublic: true }, // Only public profiles + fields: ['id', 'firstName', 'lastName', 'avatar', 'bio'], + }, + { + action: 'getAll', + subject: 'UserProfile', + conditions: { userId: '${currentUserId}' }, // Own profile + fields: ['id', 'firstName', 'lastName', 'avatar', 'bio', 'phone'], // + phone + } +] + +// Result: Filtered records + different fields per item +// GET /user-profiles +// => Database query: WHERE isPublic = true OR userId = 123 +// => [ +// { id: 1, firstName: 'John', ... }, // public profile +// { id: 2, firstName: 'Jane', phone: '...', ... }, // own profile (has phone) +// { id: 3, firstName: 'Bob', ... } // public profile +// ] +// => Items 1,3: phone field REMOVED (not in first rule) +// => Item 2: phone field VISIBLE (matches second rule) +``` + +**Key Points:** + +- ✅ **Database-level filtering**: `conditions` become WHERE clauses +- ✅ **Per-item field restrictions**: Each item can have different visible fields +- ✅ **Meta information**: `fieldRestrictions` tells which fields were hidden +- ✅ **Empty results**: If no records match ACL conditions, returns empty array per JSON:API spec +- ⚠️ **No `__current` support**: Can use only `${@input.*}` without `__current`. `${@input}` is each row from a query result +- ⚠️ **Multiple rules merge**: If multiple rules match, fields are combined (union) + +**Empty Result Example:** + +```typescript +// Rules: Only public profiles OR own profile +[ + { action: 'getAll', subject: 'UserProfile', conditions: { isPublic: true } }, + { action: 'getAll', subject: 'UserProfile', conditions: { userId: 123 } } +] + +// Database: No public profiles AND user 123 has no profile +// Result: Empty array (per JSON:API spec) +GET /user-profiles +=> { + meta: { totalItems: 0, pageNumber: 1, pageSize: 25 }, + data: [] + } +``` + +**⚠️ IMPORTANT: Query Construction Safety** + +The `ability.getQueryObject()` converts ACL conditions to database queries. **Be careful when writing rules** - complex conditions might fail to convert: + +```typescript +// ❌ BAD: Complex nested conditions that might fail conversion +{ + conditions: { + $or: [ + { 'profile.department.name': { $in: ['Sales', 'Marketing'] } }, + { 'permissions.admin': { $gt: 5 } } + ] + } +} + +// ✅ GOOD: Simple, flat conditions +{ + conditions: { + isPublic: true, + authorId: '${currentUserId}' + } +} +``` + +**Error Handling:** + +If ACL rules produce an invalid database query: + +- **Production mode** (`NODE_ENV=production`): + - Returns **403 Forbidden** (masks DB error as ACL denial) + - Logs error: `[ACL] Query error in getAllProxy for subject 'Post': ` + +- **Development mode**: + - Returns **500 Internal Server Error** (exposes DB error for debugging) + - Logs error with full stack trace + +**Example:** + +```typescript +// Rule with typo in field name: +{ + action: 'getAll', + subject: 'Post', + conditions: { auhtorId: 123 } // typo: auhtorId instead of authorId +} + +// Database error: column "auhtorId" does not exist +// → Production: 403 Forbidden +// → Development: 500 + "column 'auhtorId' does not exist" +``` + +**Recommendations:** + +1. Test ACL rules thoroughly in development +2. Use simple, flat conditions whenever possible +3. Monitor logs for ACL query errors in production +4. Validate field names match your entity schema + +--- + +### getOne - Get Single Entity + +**Flow:** + +```typescript +GET /posts/:id +↓ +1. AclGuard checks: can('getOne', 'Post') +2. ORM Proxy intercepts ormService.getOne(id, query) +3. Prepare ACL query: + - Extract conditions from ability.getQueryObject() + - Extract field restrictions from ability.getQueryObject() + - Merge user query with ACL query +4. Validate: no __current templates (not supported for getOne) +5. Execute query with ACL filtering (WHERE id = :id AND ) +6. If not found → 404 Not Found +7. Post-process result: + - Check field-level permissions for the item + - Build fieldRestrictions if fields were hidden + - Transform to JSON:API format +8. Return: { meta: { fieldRestrictions }, data, included } +``` + +**Three ACL Scenarios:** + +**1. No conditions, all fields (admin)** + +```typescript +// Rule: +{ + action: 'getOne', + subject: 'UserProfile', + // No conditions = can access any profile by ID + // No fields = all fields visible +} + +// Result: Any profile with all fields +// GET /user-profiles/1 +// => { id: 1, firstName: 'John', salary: 5000, role: 'admin', ... } +``` + +**2. No conditions, limited fields (moderator)** + +```typescript +// Rule: +{ + action: 'getOne', + subject: 'UserProfile', + fields: ['id', 'firstName', 'lastName', 'avatar', 'phone'], +} + +// Result: Any profile but some fields hidden +// GET /user-profiles/1 +// => { id: 1, firstName: 'John', lastName: 'Doe', avatar: '...', phone: '...' } +// salary and role are REMOVED +// +// meta: { +// fieldRestrictions: [{ id: 1, fields: ['salary', 'role'] }] +// } +``` + +**3. With conditions, per-item field restrictions (user)** + +```typescript +// Rules: +[ + { + action: 'getOne', + subject: 'UserProfile', + conditions: { isPublic: true }, // Only public profiles + fields: ['id', 'firstName', 'lastName', 'avatar', 'bio'], + }, + { + action: 'getOne', + subject: 'UserProfile', + conditions: { userId: '${currentUserId}' }, // Own profile + fields: ['id', 'firstName', 'lastName', 'avatar', 'bio', 'phone'], // + phone + } +] + +// Scenario A: Own profile +// GET /user-profiles/123 (currentUserId = 123) +// => Database query: WHERE id = 123 AND (isPublic = true OR userId = 123) +// => { id: 123, firstName: 'John', phone: '...', ... } // ✅ Has phone (own profile) + +// Scenario B: Public profile +// GET /user-profiles/456 (other user's public profile) +// => Database query: WHERE id = 456 AND (isPublic = true OR userId = 123) +// => { id: 456, firstName: 'Jane', ... } // ✅ No phone (public profile) + +// Scenario C: Private profile of another user +// GET /user-profiles/789 (other user's private profile) +// => Database query: WHERE id = 789 AND (isPublic = true OR userId = 123) +// => No match (not public AND not own) → 404 Not Found +``` + +**Key Points:** + +- ✅ **Database-level filtering**: `conditions` + ID filter combined with AND +- ✅ **Field restrictions**: Single item can have hidden fields +- ✅ **Meta information**: `fieldRestrictions` tells which fields were hidden +- ⚠️ **404 if not found**: If entity doesn't exist OR doesn't match ACL conditions → 404 +- ⚠️ **No `__current` support**: Can use only `${@input.*}` without `__current`. `${@input}` is row from a query result +- ⚠️ **Multiple rules merge**: If multiple rules match, fields are combined (union) + +**404 Not Found vs 403 Forbidden:** + +```typescript +// Scenario 1: Entity doesn't exist +GET /posts/99999 (doesn't exist) +→ 404 Not Found (standard behavior) + +// Scenario 2: Entity exists but ACL denies access +GET /posts/5 (exists but not public, and not yours) +→ 404 Not Found (ACL filtered it out) + +// Why 404 instead of 403? +// - Security: Don't leak information about resource existence +// - ACL filtering at DB level returns null → appears as "not found" +``` + +**Important:** getOne uses the same error handling as getAll: +- Invalid ACL rules → Production: 403, Development: 500 +- Same recommendations apply (test rules, use simple conditions, monitor logs) + +--- + +### deleteOne - Delete Single Entity + +**Flow:** + +```typescript +DELETE /posts/:id +↓ +1. AclGuard checks: can('deleteOne', 'Post') +2. ORM Proxy intercepts ormService.deleteOne(id) +3. Fetch entity without ACL filtering (just by ID) +4. If not found → throw error (404) +5. Two-stage check with @input support: + - updateWithInput(entity) - materialize rules with entity data + - Check: can('deleteOne', subject('Post', entity)) +6. If denied → 403 Forbidden +7. If allowed → execute delete +8. Return: void (successful deletion) +``` + +**Three ACL Scenarios:** + +**1. No conditions (admin)** + +```typescript +// Rule: +{ + action: 'deleteOne', + subject: 'Article', + // No conditions = can delete any article +} + +// Result: Any article can be deleted +// DELETE /articles/1 → ✅ Success (200) +// DELETE /articles/2 → ✅ Success (200) +``` + +**2. Simple conditions with @input (moderator)** + +```typescript +// Rule: +{ + action: 'deleteOne', + subject: 'Article', + conditions: { status: 'published' }, // Only published articles +} + +// Scenario A: Article is published +// DELETE /articles/1 (article.status = 'published') +// → Fetch article → updateWithInput(article) +// → Check: can('deleteOne', article) → conditions match +// → ✅ Success (200) + +// Scenario B: Article is draft +// DELETE /articles/2 (article.status = 'draft') +// → Fetch article → updateWithInput(article) +// → Check: can('deleteOne', article) → conditions don't match +// → ❌ 403 Forbidden +``` + +**3. Complex conditions with @input (user)** + +```typescript +// Rule: Only author can delete unpublished articles +{ + action: 'deleteOne', + subject: 'Article', + conditions: { + authorId: '${@input.authorId}', // Must be author + status: { $ne: 'published' } // Cannot be published + } +} + +// Scenario A: Own draft article +// DELETE /articles/5 (authorId = 123, status = 'draft', currentUserId = 123) +// → Fetch article → updateWithInput(article) +// → Materialize: authorId: 123 (from @input), status != 'published' +// → Check: can('deleteOne', article) → ✅ Both conditions match +// → ✅ Success (200) + +// Scenario B: Own published article +// DELETE /articles/6 (authorId = 123, status = 'published', currentUserId = 123) +// → Fetch article → updateWithInput(article) +// → Check: can('deleteOne', article) → ❌ status = 'published' (not allowed) +// → ❌ 403 Forbidden +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow \"deleteOne\"", +// "path": ["action"] +// }] +// } + +// Scenario C: Someone else's draft article +// DELETE /articles/7 (authorId = 456, status = 'draft', currentUserId = 123) +// → Fetch article → updateWithInput(article) +// → Check: can('deleteOne', article) → ❌ authorId doesn't match +// → ❌ 403 Forbidden +``` + +**Key Points:** + +- ✅ **Two-stage check**: Fetch entity first, then check with `@input` data +- ✅ **@input support**: Can use `${@input.field}` in conditions (access to entity data) +- ✅ **Instance-level check**: Rules evaluated against actual entity instance +- ⚠️ **403 on denial**: Returns 403 Forbidden (not 404) because entity exists and was loaded +- ⚠️ **No `__current` support**: Cannot compare old/new values (no update context) +- ⚠️ **No field restrictions**: `fields` parameter ignored for delete operations + +**403 Forbidden vs 404 Not Found:** + +```typescript +// Scenario 1: Entity doesn't exist +DELETE /articles/99999 (doesn't exist) +→ 404 Not Found (entity not found in getOne step) + +// Scenario 2: Entity exists but ACL denies deletion +DELETE /articles/5 (exists but conditions don't match) +→ 403 Forbidden (entity loaded, ACL check failed) + +// Why different from getOne? +// - getOne: ACL filtering at DB level (appears as "not found") +// - deleteOne: ACL check after loading entity (explicit denial) +``` + +**Why two-stage check?** + +deleteOne needs access to entity data for `@input` templates: + +```typescript +// This rule needs entity data: +{ + conditions: { + authorId: '${@input.authorId}', // From entity + status: { $ne: 'published' }, // From entity + createdAt: { $gt: '${@input.yesterday}' } // Computed from entity + } +} + +// Flow: +// 1. Fetch entity (no ACL filtering) +// 2. updateWithInput(entity) - materialize with entity data +// 3. Check can('deleteOne', entity) - evaluate conditions +// 4. Delete if allowed +``` + +**Important:** deleteOne uses the same error handling as getAll: +- Invalid ACL rules → Production: 403, Development: 500 +- Same recommendations apply (test rules, use simple conditions, monitor logs) + +--- + +### postOne - Create New Entity + +**Flow:** + +```typescript +POST /posts +↓ +1. AclGuard checks: can('postOne', 'Post') +2. ORM Proxy intercepts ormService.postOne(inputData) +3. Load relationships (if provided in request) +4. Build entity from attributes + loaded relationships +5. Two-stage check with @input support: + - updateWithInput(entity) - materialize rules with input data + - Check entity-level: can('postOne', subject('Post', entity)) + - Check field-level: for each changed field → can('postOne', entity, field) +6. If denied → 403 Forbidden (entity or field) +7. If allowed → execute create +8. Return: created entity with ID +``` + +**Three ACL Scenarios:** + +**1. No conditions, no field restrictions (admin)** + +```typescript +// Rule: +{ + action: 'postOne', + subject: 'Article', + // No conditions = can create with any data + // No fields = can set any fields +} + +// Result: Can create articles with any author +// POST /articles +// body: { authorId: 123, status: 'published', ... } +// → ✅ Success (201) +// +// body: { authorId: 456, status: 'published', ... } +// → ✅ Success (201) +``` + +**2. Conditions with @input (moderator)** + +```typescript +// Rule: Can only create articles where they are the author +{ + action: 'postOne', + subject: 'Article', + conditions: { + authorId: '${@input.authorId}', // Must match input authorId + } +} + +// Scenario A: Creating with own author +// POST /articles (currentUserId = 123) +// body: { authorId: 123, status: 'published', ... } +// → Build entity → updateWithInput({ authorId: 123, ... }) +// → Materialize: authorId: 123 (from @input) +// → Check: can('postOne', entity) → ✅ authorId matches +// → ✅ Success (201) + +// Scenario B: Creating with different author +// POST /articles (currentUserId = 123) +// body: { authorId: 456, status: 'published', ... } +// → Build entity → updateWithInput({ authorId: 456, ... }) +// → Check: can('postOne', entity) → ❌ authorId doesn't match (456 != 123) +// → ❌ 403 Forbidden +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow \"postOne\"", +// "path": ["action"] +// }] +// } +``` + +**3. Conditions + field restrictions (user)** + +```typescript +// Rule: Can create draft articles, only specific fields allowed +{ + action: 'postOne', + subject: 'Article', + conditions: { + authorId: '${@input.authorId}', // Must be own article + status: 'draft' // Must be draft + }, + fields: ['title', 'content', 'authorId', 'status'] // Only these fields +} + +// Scenario A: Create draft with allowed fields +// POST /articles (currentUserId = 123) +// body: { authorId: 123, status: 'draft', title: 'Test', content: '...' } +// → Build entity → updateWithInput(entity) +// → Check entity: can('postOne', entity) → ✅ Conditions match +// → Check fields: +// - can('postOne', entity, 'authorId') → ✅ In fields list +// - can('postOne', entity, 'status') → ✅ In fields list +// - can('postOne', entity, 'title') → ✅ In fields list +// - can('postOne', entity, 'content') → ✅ In fields list +// → ✅ Success (201) + +// Scenario B: Try to create published article +// POST /articles (currentUserId = 123) +// body: { authorId: 123, status: 'published', title: 'Test' } +// → Build entity → updateWithInput(entity) +// → Check entity: can('postOne', entity) → ❌ status != 'draft' +// → ❌ 403 Forbidden (entity-level) + +// Scenario C: Try to set forbidden field +// POST /articles (currentUserId = 123) +// body: { authorId: 123, status: 'draft', title: 'Test', publishedAt: new Date() } +// → Build entity → updateWithInput(entity) +// → Check entity: can('postOne', entity) → ✅ Conditions match +// → Check fields: +// - can('postOne', entity, 'authorId') → ✅ Allowed +// - can('postOne', entity, 'status') → ✅ Allowed +// - can('postOne', entity, 'title') → ✅ Allowed +// - can('postOne', entity, 'publishedAt') → ❌ NOT in fields list! +// → ❌ 403 Forbidden (field-level) +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow to set field \"publishedAt\"", +// "path": ["data", "attributes", "publishedAt"] +// }] +// } +``` + +**Key Points:** + +- ✅ **Two-stage check**: Entity-level check + field-level check for each input field +- ✅ **@input support**: Can use `${@input.field}` in conditions (access to input data) +- ✅ **Field-level restrictions**: Each input field checked individually with `can(action, entity, field)` +- ✅ **Relationships loaded**: If relationships provided, they are loaded and merged with attributes +- ⚠️ **403 on denial**: Returns 403 Forbidden with specific error (entity or field) +- ⚠️ **No `__current` support**: Cannot compare old/new values (no existing entity context) +- ⚠️ **Changed fields only**: Only fields present in input (attributes + relationships) are checked + +**Entity-level vs Field-level errors:** + +```typescript +// Entity-level error (conditions don't match): +{ + "errors": [{ + "code": "forbidden", + "message": "not allow \"postOne\"", + "path": ["action"] + }] +} + +// Field-level error (specific field not allowed): +{ + "errors": [{ + "code": "forbidden", + "message": "not allow to set field \"publishedAt\"", + "path": ["data", "attributes", "publishedAt"] // Precise location + }] +} +``` + +**Why two checks?** + +postOne needs fine-grained control: + +1. **Entity-level**: Validate overall entity state (e.g., "must be draft", "must be own article") +2. **Field-level**: Validate which fields user can set (e.g., "can't set publishedAt", "can't set adminOnly fields") + +This allows rules like: "Users can create draft posts but can't set publishedAt or moderatorNotes fields" + +**Important:** postOne uses the same error handling as getAll: +- Invalid ACL rules → Production: 403, Development: 500 +- Same recommendations apply (test rules, use simple conditions, monitor logs) + +--- + +### patchOne - Update Single Entity + +**Flow:** + +```typescript +PATCH /posts/:id +↓ +1. AclGuard checks: can('patchOne', 'Post') +2. ORM Proxy intercepts ormService.patchOne(id, inputData) +3. Fetch entity from database (with ACL conditions for access check) +4. If not found → 404 Not Found +5. Load relationships (if provided in request) +6. Detect changed fields (compare old vs new values) +7. Build entity for check with __current: + - Root level: NEW values (after applying changes) + - __current: OLD values (from database) +8. Two-stage check with @input + __current support: + - updateWithInput(entityForCheck) - materialize rules with old/new data + - Check entity-level: can('patchOne', subject('Post', entityForCheck)) + - Check field-level: for each changed field → can('patchOne', entityForCheck, field) +9. If denied → 403 Forbidden (entity or field) +10. If allowed → execute update +11. Return: updated entity +``` + +**The `__current` Magic 🪄** + +patchOne has a unique feature: access to **both old and new values** simultaneously: + +```typescript +// Entity structure during ACL check: +{ + ...newValues, // Root level: values AFTER update + __current: oldValues // Nested: values BEFORE update (from DB) +} +``` + +This enables rules like: +- "Allow changing status from draft to review, but not to published" +- "Allow removing only yourself from coAuthors" +- "Allow increasing price, but not decreasing it" + +**⚠️ Yes, this looks a bit hacky** (we know! 😅), but after extensive brainstorming, this was the cleanest solution we found for comparing old/new values in CASL rules. **If you have a better idea**, we'd love to hear it! Open a [GitHub discussion](https://github.com/klerick/nestjs-json-api/discussions) or submit a PR! 🙏 + +**Three ACL Scenarios:** + +**1. No conditions, no field restrictions (admin)** + +```typescript +// Rule: +{ + action: 'patchOne', + subject: 'Article', + // No conditions = can update any article + // No fields = can update any fields +} + +// Result: Can update any article, any fields +// PATCH /articles/1 +// body: { title: 'New title', status: 'published' } +// → ✅ Success (200) +``` + +**2. Field restrictions + value validation (moderator)** + +```typescript +// Rule: Can update non-published articles, specific fields + value constraints +{ + action: 'patchOne', + subject: 'Article', + conditions: { + '__current.status': { $ne: 'published' } // ⚠️ Using __current! + }, + fields: ['status', 'content'] // Only these fields can be changed +} + +// Additional field-level rules with value constraints: +{ + action: 'patchOne', + subject: 'Article', + conditions: { + '__current.status': { $ne: 'published' }, + 'status': { $in: ['draft', 'review'] } // Can only set to draft or review + }, + fields: ['status'] +} + +// Scenario A: Update draft article with allowed field +// PATCH /articles/1 (current status = 'draft') +// body: { status: 'review' } +// → Fetch article (status: 'draft') +// → Build entityForCheck: { status: 'review', __current: { status: 'draft', ... } } +// → Check entity: __current.status != 'published' ✅, status in ['draft', 'review'] ✅ +// → Check field 'status': in fields list ✅ +// → ✅ Success (200) + +// Scenario B: Try to update published article +// PATCH /articles/2 (current status = 'published') +// body: { status: 'review' } +// → entityForCheck: { status: 'review', __current: { status: 'published', ... } } +// → Check entity: __current.status != 'published' ❌ +// → ❌ 403 Forbidden (entity-level) + +// Scenario C: Try to change not-allowed field +// PATCH /articles/1 (current status = 'draft') +// body: { title: 'New title' } +// → Changed fields: ['title'] +// → Check field 'title': NOT in fields list ❌ +// → ❌ 403 Forbidden (field-level) +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow to modify field \"title\"", +// "path": ["data", "attributes", "title"] +// }] +// } + +// Scenario D: Try to set forbidden value +// PATCH /articles/1 (current status = 'draft') +// body: { status: 'published' } +// → entityForCheck: { status: 'published', __current: { status: 'draft', ... } } +// → Check entity: status NOT in ['draft', 'review'] ❌ +// → ❌ 403 Forbidden (entity-level) +``` + +**3. Complex __current rule: Remove only yourself from coAuthors (user)** + +```typescript +// In your RulesLoader service (implements AclRulesLoader interface) +@Injectable() +export class MyRulesLoaderService implements AclRulesLoader { + // Helper functions available in rules + async getHelpers(): Promise unknown>> { + return { + // Helper to calculate expected array (old array without user) + removeMyselfOnly: (oldArray: number[], userId: number): number[] => { + return oldArray.filter(id => id !== userId); + } + }; + } + + async loadRules(entity: any, action: string): Promise[]> { + // ... your rules + } +} + +// Module configuration +AclPermissionsModule.forRoot({ + rulesLoader: MyRulesLoaderService, // ← Helper functions come from here + contextStore: ClsService, + onNoRules: 'deny', +}) + +// Rule: CoAuthor can update ONLY to remove themselves from coAuthorIds +{ + action: 'patchOne', + subject: 'Article', + conditions: { + '__current.coAuthorIds': { $in: ['${currentUser.id}'] }, // WAS in old array + 'coAuthorIds': { + $all: '${removeMyselfOnly(@input.__current.coAuthorIds, currentUser.id)}', // New array = old array - self + $size: '${@input.__current.coAuthorIds.length - 1}' // Size decreased by 1 + } + }, + fields: ['coAuthorIds'] +} + +// Additional rule: Author can update article +{ + action: 'patchOne', + subject: 'Article', + conditions: { + 'authorId': '${currentUser.id}' // Is the author + } +} + +// Scenario A: CoAuthor removes only themselves ✅ +// PATCH /articles/1 (currentUserId = 5, article.coAuthorIds = [3, 5, 7]) +// body: { coAuthorIds: [3, 7] } // Removed 5 +// → entityForCheck: { +// coAuthorIds: [3, 7], +// __current: { coAuthorIds: [3, 5, 7], ... } +// } +// → Materialize: +// __current.coAuthorIds: [3, 5, 7] contains 5 ✅ +// coAuthorIds: [3, 7] does NOT contain 5 ✅ +// → ✅ Success (200) - Removed themselves + +// Scenario B: CoAuthor tries to add someone ❌ +// PATCH /articles/1 (currentUserId = 5, article.coAuthorIds = [3, 5, 7]) +// body: { coAuthorIds: [3, 5, 7, 9] } // Added 9, kept themselves +// → entityForCheck: { +// coAuthorIds: [3, 5, 7, 9], +// __current: { coAuthorIds: [3, 5, 7], ... } +// } +// → Check: coAuthorIds contains 5 ❌ (must NOT contain for rule to match) +// → ❌ 403 Forbidden + +// Scenario C: CoAuthor removes themselves + adds someone ❌ +// PATCH /articles/1 (currentUserId = 5, article.coAuthorIds = [3, 5, 7]) +// body: { coAuthorIds: [3, 7, 9] } // Removed 5, added 9 +// → Check entity-level: 5 was in old ✅, 5 not in new ✅ +// → BUT coAuthorIds field changed from [3, 5, 7] to [3, 7, 9] +// → This changes OTHER authors (added 9) → field validation fails +// → ❌ 403 Forbidden (entity-level passes, but adding others violates intent) + +// Scenario D: Author updates article ✅ +// PATCH /articles/1 (currentUserId = 10, article.authorId = 10) +// body: { title: 'New title' } +// → Matches second rule (authorId matches) +// → ✅ Success (200) +``` + +**Why this `__current` pattern?** + +This rule prevents coAuthors from: +- ❌ Adding other coAuthors +- ❌ Removing other coAuthors +- ❌ Staying in the array (keeping themselves) + +They can ONLY: +- ✅ Remove themselves completely + +Without `__current`, you couldn't express "was present but now removed" logic! + +**Key Points:** + +- ✅ **Two-stage check**: Entity-level + field-level for each changed field +- ✅ **@input support**: Access to new values via `${@input.field}` +- ✅ **__current support**: Access to old values via `${@input.__current.field}` 🪄 +- ✅ **Changed fields detection**: Compares DB values vs request values +- ✅ **Field-level restrictions**: Each changed field checked individually +- ✅ **Relationships loaded**: If relationships in request, they are loaded +- ⚠️ **403 on denial**: Returns 403 Forbidden (entity or field) +- ⚠️ **Only changed fields checked**: Unchanged fields are not validated + +**Changed Fields Detection:** + +patchOne compares old (DB) vs new (request) values to detect changes: + +```typescript +// Comparison strategy: +// - Primitives (string, number, boolean, null): strict equality (===) +// - Date objects: toISOString() comparison +// - Objects/Arrays: JSON.stringify comparison + +// Examples: +// Old: { title: 'Hello' } +// New: { title: 'Hello' } +// → Changed: [] (no changes) + +// Old: { title: 'Hello' } +// New: { title: 'World' } +// → Changed: ['title'] + +// Old: { tags: [1, 2, 3] } +// New: { tags: [1, 2, 3] } +// → Changed: [] (JSON.stringify matches) + +// Old: { tags: [1, 2, 3] } +// New: { tags: [1, 2, 3, 4] } +// → Changed: ['tags'] (JSON.stringify differs) +``` + +**⚠️ Known Edge Cases (~10% of use cases):** + +1. **JSONB fields with different key order** may trigger false positives: + ```typescript + // Old: { metadata: { a: 1, b: 2 } } + // New: { metadata: { b: 2, a: 1 } } + // → Detected as CHANGED (JSON.stringify differs) + // → But content is identical! + ``` + +2. **Date comparison**: Uses `toISOString()`, so different Date objects with same time are treated as equal. + +3. **Circular references**: Not expected in JSON:API requests (would fail JSON.parse anyway). + +**If you encounter issues with changed field detection, please [create a GitHub issue](https://github.com/klerick/nestjs-json-api/issues) with your use case!** + +**Entity-level vs Field-level errors:** + +```typescript +// Entity-level error (__current conditions don't match): +{ + "errors": [{ + "code": "forbidden", + "message": "not allow \"patchOne\"", + "path": ["action"] + }] +} + +// Field-level error (specific field not allowed): +{ + "errors": [{ + "code": "forbidden", + "message": "not allow to modify field \"status\"", + "path": ["data", "attributes", "status"] + }] +} +``` + +**Important:** patchOne uses the same error handling as getAll: +- Invalid ACL rules → Production: 403, Development: 500 +- Same recommendations apply (test rules, use simple conditions, monitor logs) + +--- + +### getRelationship - Get Relationship Data + +**Flow:** + +```typescript +GET /posts/:id/relationships/:relName +↓ +1. AclGuard checks: can('getRelationship', 'Post') +2. ORM Proxy intercepts ormService.getRelationship(id, relName) +3. Prepare ACL query with relationship include +4. Fetch entity with relationship (getOne with ACL conditions + include) +5. If not found → 404 Not Found +6. Two-stage check with @input + field-level: + - updateWithInput(entity) - materialize rules with entity data + - Check: can('getRelationship', subject('Post', entity), relName) +7. If denied → 403 Forbidden +8. If allowed → return relationship data +``` + +**Three ACL Scenarios:** + +**1. No conditions, no field restrictions (admin)** + +```typescript +// Rule: +{ + action: 'getRelationship', + subject: 'UsersAcl', + // No conditions = can access any user's relationships + // No fields = can access all relationships +} + +// Result: Can access any relationship for any user +// GET /users-acl/1/relationships/profile → ✅ Success (200) +// GET /users-acl/1/relationships/posts → ✅ Success (200) +// GET /users-acl/2/relationships/profile → ✅ Success (200) +``` + +**2. No conditions, with field restrictions (moderator)** + +```typescript +// Rule: +{ + action: 'getRelationship', + subject: 'UsersAcl', + fields: ['posts'], // Only 'posts' relationship allowed +} + +// Scenario A: Access allowed relationship +// GET /users-acl/1/relationships/posts +// → Fetch user with posts → updateWithInput(user) +// → Check: can('getRelationship', user, 'posts') → ✅ 'posts' in fields +// → ✅ Success (200) + +// Scenario B: Access forbidden relationship +// GET /users-acl/1/relationships/profile +// → Fetch user with profile → updateWithInput(user) +// → Check: can('getRelationship', user, 'profile') → ❌ 'profile' NOT in fields +// → ❌ 403 Forbidden +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow \"getRelationship\"", +// "path": ["action"] +// }] +// } +``` + +**3. With conditions + field restrictions (user)** + +```typescript +// Rule: Can only access own relationships +{ + action: 'getRelationship', + subject: 'UsersAcl', + conditions: { + id: '${currentUser.id}' // Must be own user + }, + fields: ['profile', 'posts'] // Only these relationships +} + +// Scenario A: Access own profile relationship +// GET /users-acl/5/relationships/profile (currentUser.id = 5) +// → Database query: WHERE id = 5 AND id = 5 (conditions match) +// → Fetch user → updateWithInput(user) +// → Check: can('getRelationship', user, 'profile') → ✅ 'profile' in fields +// → ✅ Success (200) + +// Scenario B: Access own posts relationship +// GET /users-acl/5/relationships/posts (currentUser.id = 5) +// → Fetch user → updateWithInput(user) +// → Check: can('getRelationship', user, 'posts') → ✅ 'posts' in fields +// → ✅ Success (200) + +// Scenario C: Try to access other user's profile +// GET /users-acl/10/relationships/profile (currentUser.id = 5) +// → Database query: WHERE id = 10 AND id = 5 (conditions don't match) +// → Entity not found (filtered by ACL conditions) +// → ❌ 403 Forbidden + +// Scenario D: Try to access forbidden relationship +// GET /users-acl/5/relationships/comments (currentUser.id = 5) +// → Fetch user → updateWithInput(user) +// → Check: can('getRelationship', user, 'comments') → ❌ 'comments' NOT in fields +// → ❌ 403 Forbidden +``` + +**Key Points:** + +- ✅ **Two-stage check**: Entity-level (fetch with conditions) + field-level (relationship name) +- ✅ **@input support**: Can use `${@input.field}` in conditions (entity data available) +- ✅ **Field = Relationship name**: `fields` array contains allowed relationship names +- ✅ **404 vs 403**: Entity not found (conditions) → 403, relationship not allowed (fields) → 403 +- ⚠️ **No `__current` support**: Cannot use `${@input.__current.*}` (not supported for getRelationship) +- ⚠️ **Entity fetched with relationship**: Uses getOne under the hood with include + +**How field-level check works:** + +```typescript +// fields parameter contains relationship names: +{ + action: 'getRelationship', + subject: 'Post', + fields: ['author', 'comments', 'tags'] // Allowed relationships +} + +// Check performed: +can('getRelationship', entity, 'author') // ✅ in fields +can('getRelationship', entity, 'comments') // ✅ in fields +can('getRelationship', entity, 'tags') // ✅ in fields +can('getRelationship', entity, 'category') // ❌ NOT in fields → 403 +``` + +**404 Not Found vs 403 Forbidden:** + +```typescript +// Scenario 1: Entity doesn't exist +GET /posts/99999/relationships/author +→ 404 Not Found (entity not found) + +// Scenario 2: Entity exists but ACL conditions deny access +GET /posts/5/relationships/author (conditions don't match) +→ 403 Forbidden (filtered by ACL conditions) + +// Scenario 3: Entity exists, conditions match, but relationship not allowed +GET /posts/5/relationships/author (entity accessible, but 'author' not in fields) +→ 403 Forbidden (relationship not in fields list) + +// Why 403 instead of 404? +// - Consistent with getOne behavior when ACL filters +// - Don't leak information about resource existence +``` + +**Important:** getRelationship uses the same error handling as getAll: +- Invalid ACL rules → Production: 403, Development: 500 +- Same recommendations apply (test rules, use simple conditions, monitor logs) + +--- + +### deleteRelationship - Remove from Relationship + +**Flow:** + +```typescript +DELETE /posts/:id/relationships/:relName +↓ +1. AclGuard checks: can('deleteRelationship', 'Post') +2. ORM Proxy intercepts ormService.deleteRelationship(id, relName, input) +3. Prepare ACL query with relationship include +4. Fetch entity with relationship (getOne with ACL conditions + include) +5. If not found → 404 Not Found +6. Filter relationship items to only those being deleted (from input.data) +7. Two-stage check with @input + field-level: + - updateWithInput(filteredEntity) - materialize rules with filtered data + - Check: can('deleteRelationship', subject('Post', filteredEntity), relName) +8. If denied → 403 Forbidden +9. If allowed → execute delete +10. Return: void (successful deletion) +``` + +**⚠️ Important filtering behavior:** + +Before ACL check, the relationship items are filtered to **only those being deleted**: + +```typescript +// Request: DELETE /users/5/relationships/aclComments +// body: { data: [{ type: 'comments', id: 10 }, { type: 'comments', id: 20 }] } + +// Entity from DB: { id: 5, aclComments: [10, 15, 20, 25] } +// Filtered for ACL check: { id: 5, aclComments: [10, 20] } // Only items being deleted! +``` + +This allows rules like: "Can delete only comments authored by current user" + +**Three ACL Scenarios:** + +**1. No conditions, no field restrictions (admin)** + +```typescript +// Rule: +{ + action: 'deleteRelationship', + subject: 'UsersAcl', + // No conditions = can delete any user's relationships + // No fields = can delete all relationships +} + +// Result: Can delete any relationship for any user +// DELETE /users-acl/1/relationships/aclComments → ✅ Success (200) +// DELETE /users-acl/1/relationships/posts → ✅ Success (200) +// DELETE /users-acl/2/relationships/aclComments → ✅ Success (200) +``` + +**2. No conditions, with field restrictions (moderator)** + +```typescript +// Rule: +{ + action: 'deleteRelationship', + subject: 'UsersAcl', + fields: ['posts'], // Only 'posts' relationship allowed +} + +// Scenario A: Delete allowed relationship +// DELETE /users-acl/1/relationships/posts +// body: { data: [{ type: 'posts', id: 5 }] } +// → Fetch user with posts → Filter to posts being deleted +// → updateWithInput(user) → Check: can('deleteRelationship', user, 'posts') +// → ✅ 'posts' in fields → Success (200) + +// Scenario B: Delete forbidden relationship +// DELETE /users-acl/1/relationships/aclComments +// body: { data: [{ type: 'comments', id: 10 }] } +// → Fetch user with aclComments → Filter to comments being deleted +// → updateWithInput(user) → Check: can('deleteRelationship', user, 'aclComments') +// → ❌ 'aclComments' NOT in fields → 403 Forbidden +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow \"deleteRelationship\"", +// "path": ["action"] +// }] +// } +``` + +**3. With conditions + field restrictions (user)** + +```typescript +// Rule: Can only delete own aclComments +{ + action: 'deleteRelationship', + subject: 'UsersAcl', + conditions: { + id: '${currentUser.id}' // Must be own user + }, + fields: ['aclComments'] // Only this relationship +} + +// Scenario A: Delete own comments +// DELETE /users-acl/5/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 10 }, { type: 'comments', id: 20 }] } +// → Database query: WHERE id = 5 AND id = 5 (conditions match) +// → Fetch user with aclComments: { id: 5, aclComments: [10, 15, 20, 25] } +// → Filter to items being deleted: { id: 5, aclComments: [10, 20] } +// → updateWithInput(filteredUser) +// → Check: can('deleteRelationship', user, 'aclComments') +// → ✅ conditions match + 'aclComments' in fields → Success (200) + +// Scenario B: Try to delete someone else's comments +// DELETE /users-acl/10/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 30 }] } +// → Database query: WHERE id = 10 AND id = 5 (conditions don't match) +// → Entity not found (filtered by ACL conditions) +// → ❌ 403 Forbidden + +// Scenario C: Try to delete forbidden relationship +// DELETE /users-acl/5/relationships/posts (currentUser.id = 5) +// body: { data: [{ type: 'posts', id: 7 }] } +// → Fetch user → Filter → updateWithInput(user) +// → Check: can('deleteRelationship', user, 'posts') +// → ❌ 'posts' NOT in fields → 403 Forbidden +``` + +**Advanced: Conditional delete based on relationship content** + +The filtering behavior enables powerful rules based on what's being deleted: + +```typescript +// Rule: User can delete ONLY their own comments from ANY user's aclComments +{ + action: 'deleteRelationship', + subject: 'UsersAcl', + conditions: { + 'aclComments': { + $all: { authorId: '${currentUser.id}' } // All items being deleted must be authored by current user + } + }, + fields: ['aclComments'] +} + +// Scenario A: Delete only own comments ✅ +// DELETE /users-acl/10/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 100 }, { type: 'comments', id: 105 }] } +// → Fetch user: { id: 10, aclComments: [ +// { id: 100, authorId: 5, text: '...' }, +// { id: 102, authorId: 10, text: '...' }, +// { id: 105, authorId: 5, text: '...' } +// ]} +// → Filter to items being deleted: { id: 10, aclComments: [ +// { id: 100, authorId: 5, text: '...' }, +// { id: 105, authorId: 5, text: '...' } +// ]} +// → Check: All items have authorId = 5 ✅ → Success (200) + +// Scenario B: Try to delete someone else's comment ❌ +// DELETE /users-acl/10/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 100 }, { type: 'comments', id: 102 }] } +// → Filter to items being deleted: { id: 10, aclComments: [ +// { id: 100, authorId: 5, text: '...' }, +// { id: 102, authorId: 10, text: '...' } // ← Not by current user! +// ]} +// → Check: NOT all items have authorId = 5 ❌ → 403 Forbidden +``` + +**Key Points:** + +- ✅ **Two-stage check**: Entity-level (fetch with conditions) + field-level (relationship name) +- ✅ **@input support**: Can use `${@input.field}` in conditions (entity data available) +- ✅ **Filtered data**: Entity filtered to only items being deleted before ACL check +- ✅ **Field = Relationship name**: `fields` array contains allowed relationship names +- ✅ **Powerful conditions**: Can check properties of items being deleted +- ⚠️ **No `__current` support**: Cannot use `${@input.__current.*}` (not supported) +- ⚠️ **To-many vs to-one**: Filtering works for both relationship types + +**How filtering works:** + +```typescript +// To-many relationship (array): +// DB: { id: 5, comments: [1, 2, 3, 4, 5] } +// Request: DELETE comments [2, 4] +// Filtered: { id: 5, comments: [2, 4] } // Only items being deleted + +// To-one relationship (single object): +// DB: { id: 5, author: { id: 10, name: 'John' } } +// Request: DELETE author +// Filtered: { id: 5, author: { id: 10, name: 'John' } } // Kept as-is +``` + +**404 Not Found vs 403 Forbidden:** + +```typescript +// Scenario 1: Entity doesn't exist +DELETE /posts/99999/relationships/comments +→ 404 Not Found (entity not found) + +// Scenario 2: Entity exists but ACL conditions deny access +DELETE /posts/5/relationships/comments (conditions don't match) +→ 403 Forbidden (filtered by ACL conditions) + +// Scenario 3: Entity exists, conditions match, but relationship not allowed +DELETE /posts/5/relationships/comments (entity accessible, but 'comments' not in fields) +→ 403 Forbidden (relationship not in fields list) + +// Why 403 instead of 404? +// - Consistent with other relationship methods +// - Don't leak information about resource existence +``` + +**Use cases:** + +1. **Simple field restriction**: "Users can only delete posts relationships, not comments" +2. **Owner-only deletion**: "Users can only delete relationships from their own entities" +3. **Content-based deletion**: "Users can only delete comments they authored" +4. **Hybrid**: "Moderators can delete any comments, users only their own" + +**Important:** deleteRelationship uses the same error handling as getAll: +- Invalid ACL rules → Production: 403, Development: 500 +- Same recommendations apply (test rules, use simple conditions, monitor logs) + +--- + +### postRelationship - Add to Relationship + +**Flow:** + +```typescript +POST /posts/:id/relationships/:relName +↓ +1. AclGuard checks: can('postRelationship', 'Post') +2. ORM Proxy intercepts ormService.postRelationship(id, relName, input) +3. Prepare ACL query (without relationship include) +4. Fetch entity without relationships (getOne with ACL conditions, no include) +5. If not found → 404 Not Found +6. Load relationships being added (from input.data) via loadRelations +7. Merge entity with loaded relationships +8. Two-stage check with @input + field-level: + - updateWithInput(mergedEntity) - materialize rules with entity + new relationships + - Check: can('postRelationship', subject('Post', mergedEntity), relName) +9. If denied → 403 Forbidden +10. If allowed → execute add +11. Return: void (successful addition) +``` + +**⚠️ Important: Relationship Loading** + +Before ACL check, **new relationships are loaded** from the input: + +```typescript +// Request: POST /users/5/relationships/aclComments +// body: { data: [{ type: 'comments', id: 10 }, { type: 'comments', id: 20 }] } + +// Entity from DB: { id: 5, aclComments: [15, 25] } // Existing comments +// Load relationships: [{ id: 10, authorId: 5, ... }, { id: 20, authorId: 3, ... }] // NEW comments +// Merged for ACL check: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] } // Only NEW! +``` + +This allows rules like: "Can add only comments authored by current user" + +**Three ACL Scenarios:** + +**1. No conditions, no field restrictions (admin)** + +```typescript +// Rule: +{ + action: 'postRelationship', + subject: 'UsersAcl', + // No conditions = can add to any user's relationships + // No fields = can add to all relationships +} + +// Result: Can add to any relationship for any user +// POST /users-acl/1/relationships/aclComments → ✅ Success (200) +// POST /users-acl/1/relationships/posts → ✅ Success (200) +// POST /users-acl/2/relationships/aclComments → ✅ Success (200) +``` + +**2. No conditions, with field restrictions (moderator)** + +```typescript +// Rule: +{ + action: 'postRelationship', + subject: 'UsersAcl', + fields: ['posts'], // Only 'posts' relationship allowed +} + +// Scenario A: Add to allowed relationship +// POST /users-acl/1/relationships/posts +// body: { data: [{ type: 'posts', id: 5 }] } +// → Fetch user → Load post (id: 5) +// → Merge: { id: 1, posts: [{ id: 5, ... }] } +// → updateWithInput(merged) → Check: can('postRelationship', user, 'posts') +// → ✅ 'posts' in fields → Success (200) + +// Scenario B: Add to forbidden relationship +// POST /users-acl/1/relationships/aclComments +// body: { data: [{ type: 'comments', id: 10 }] } +// → Fetch user → Load comment (id: 10) +// → Merge: { id: 1, aclComments: [{ id: 10, ... }] } +// → updateWithInput(merged) → Check: can('postRelationship', user, 'aclComments') +// → ❌ 'aclComments' NOT in fields → 403 Forbidden +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow \"postRelationship\"", +// "path": ["action"] +// }] +// } +``` + +**3. With conditions + field restrictions (user)** + +```typescript +// Rule: Can only add aclComments to own user +{ + action: 'postRelationship', + subject: 'UsersAcl', + conditions: { + id: '${currentUser.id}' // Must be own user + }, + fields: ['aclComments'] // Only this relationship +} + +// Scenario A: Add to own aclComments +// POST /users-acl/5/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 10 }, { type: 'comments', id: 20 }] } +// → Database query: WHERE id = 5 AND id = 5 (conditions match) +// → Fetch user: { id: 5, aclComments: [15, 25] } +// → Load relationships: [{ id: 10, ... }, { id: 20, ... }] +// → Merge: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] } +// → updateWithInput(merged) +// → Check: can('postRelationship', user, 'aclComments') +// → ✅ conditions match + 'aclComments' in fields → Success (200) + +// Scenario B: Try to add to someone else's aclComments +// POST /users-acl/10/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 30 }] } +// → Database query: WHERE id = 10 AND id = 5 (conditions don't match) +// → Entity not found (filtered by ACL conditions) +// → ❌ 403 Forbidden + +// Scenario C: Try to add to forbidden relationship +// POST /users-acl/5/relationships/posts (currentUser.id = 5) +// body: { data: [{ type: 'posts', id: 7 }] } +// → Fetch user → Load post → Merge +// → Check: can('postRelationship', user, 'posts') +// → ❌ 'posts' NOT in fields → 403 Forbidden +``` + +**Advanced: Conditional add based on relationship content** + +The loading behavior enables powerful rules based on what's being added: + +```typescript +// Rule: User can add ONLY their own comments to ANY user's aclComments +{ + action: 'postRelationship', + subject: 'UsersAcl', + conditions: { + 'aclComments': { + $all: { authorId: '${currentUser.id}' } // All items being added must be authored by current user + } + }, + fields: ['aclComments'] +} + +// Scenario A: Add only own comments ✅ +// POST /users-acl/10/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 100 }, { type: 'comments', id: 105 }] } +// → Fetch user: { id: 10, aclComments: [...] } +// → Load comments: [ +// { id: 100, authorId: 5, text: '...' }, +// { id: 105, authorId: 5, text: '...' } +// ] +// → Merge: { id: 10, aclComments: [ +// { id: 100, authorId: 5, text: '...' }, +// { id: 105, authorId: 5, text: '...' } +// ]} +// → Check: All items have authorId = 5 ✅ → Success (200) + +// Scenario B: Try to add someone else's comment ❌ +// POST /users-acl/10/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 100 }, { type: 'comments', id: 102 }] } +// → Load comments: [ +// { id: 100, authorId: 5, text: '...' }, +// { id: 102, authorId: 10, text: '...' } // ← Not by current user! +// ] +// → Merge: { id: 10, aclComments: [ +// { id: 100, authorId: 5, text: '...' }, +// { id: 102, authorId: 10, text: '...' } +// ]} +// → Check: NOT all items have authorId = 5 ❌ → 403 Forbidden +``` + +**Key Points:** + +- ✅ **Two-stage check**: Entity-level (fetch with conditions) + field-level (relationship name) +- ✅ **@input support**: Can use `${@input.field}` in conditions (entity + new relationships available) +- ✅ **Loaded relationships**: New relationships loaded via `loadRelations` before ACL check +- ✅ **Field = Relationship name**: `fields` array contains allowed relationship names +- ✅ **Powerful conditions**: Can check properties of items being added +- ⚠️ **No `__current` support**: Cannot use `${@input.__current.*}` (not supported) +- ⚠️ **To-many vs to-one**: Loading works for both relationship types + +**How loading works:** + +```typescript +// To-many relationship (array): +// DB: { id: 5, comments: [1, 2, 3] } // Existing +// Request: POST comments [4, 5] +// Loaded: [{ id: 4, ... }, { id: 5, ... }] +// Merged: { id: 5, comments: [{ id: 4, ... }, { id: 5, ... }] } // Only NEW items! + +// To-one relationship (single object): +// DB: { id: 5, author: { id: 10, name: 'John' } } // Existing +// Request: POST author [{ id: 20 }] +// Loaded: { id: 20, name: 'Jane' } +// Merged: { id: 5, author: { id: 20, name: 'Jane' } } // NEW author replaces +``` + +**404 Not Found vs 403 Forbidden:** + +```typescript +// Scenario 1: Entity doesn't exist +POST /posts/99999/relationships/comments +→ 404 Not Found (entity not found) + +// Scenario 2: Entity exists but ACL conditions deny access +POST /posts/5/relationships/comments (conditions don't match) +→ 403 Forbidden (filtered by ACL conditions) + +// Scenario 3: Entity exists, conditions match, but relationship not allowed +POST /posts/5/relationships/comments (entity accessible, but 'comments' not in fields) +→ 403 Forbidden (relationship not in fields list) + +// Why 403 instead of 404? +// - Consistent with other relationship methods +// - Don't leak information about resource existence +``` + +**Use cases:** + +1. **Simple field restriction**: "Users can only add posts relationships, not comments" +2. **Owner-only addition**: "Users can only add relationships to their own entities" +3. **Content-based addition**: "Users can only add comments they authored" +4. **Hybrid**: "Moderators can add any comments, users only their own" + +**Important:** postRelationship uses the same error handling as getAll: +- Invalid ACL rules → Production: 403, Development: 500 +- Same recommendations apply (test rules, use simple conditions, monitor logs) + +--- + +### patchRelationship - Replace Relationship + +**Flow:** + +```typescript +PATCH /posts/:id/relationships/:relName +↓ +1. AclGuard checks: can('patchRelationship', 'Post') +2. ORM Proxy intercepts ormService.patchRelationship(id, relName, input) +3. Prepare ACL query with relationship include +4. Fetch entity with OLD relationship (getOne with ACL conditions + include) +5. If not found → 404 Not Found +6. Load NEW relationships being set (from input.data) via loadRelations +7. Create entityToCheck with __current support: + - Root level = entity with NEW relationships + - __current = entity with OLD relationships +8. Two-stage check with @input + field-level: + - updateWithInput(entityToCheck) - materialize rules with NEW + OLD data + - Check: can('patchRelationship', subject('Post', entityToCheck), relName) +9. If denied → 403 Forbidden +10. If allowed → execute replace +11. Return: void (successful replacement) +``` + +**⚠️ Important: `__current` Support** + +Unlike postRelationship and deleteRelationship, **patchRelationship supports `__current`** (like patchOne): + +```typescript +// Request: PATCH /users/5/relationships/aclComments +// body: { data: [{ type: 'comments', id: 30 }, { type: 'comments', id: 40 }] } + +// Step 1: Fetch entity with OLD relationships +const oldEntity = { + id: 5, + aclComments: [{ id: 10, authorId: 5 }, { id: 20, authorId: 5 }] // OLD +}; + +// Step 2: Load NEW relationships from input +const newRelationships = [ + { id: 30, authorId: 5 }, + { id: 40, authorId: 10 } +]; // NEW + +// Step 3: Create entityToCheck with __current +const entityToCheck = { + id: 5, + aclComments: [{ id: 30, ... }, { id: 40, ... }], // NEW relationships at root + __current: { + id: 5, + aclComments: [{ id: 10, ... }, { id: 20, ... }] // OLD entity with OLD relationships + } +}; + +// Step 4: Check rules - can access BOTH old and new values +can('patchRelationship', subject('UsersAcl', entityToCheck), 'aclComments'); +// Rules can use: +// - ${@input.aclComments.map(i => i.id)} → [30, 40] (NEW values) +// - ${@input.__current.aclComments.map(i => i.id)} → [10, 20] (OLD values) +``` + +This enables rules like: "Can only replace relationships if not removing any items" + +**Three ACL Scenarios:** + +**1. No conditions, no field restrictions (admin)** + +```typescript +// Rule: +{ + action: 'patchRelationship', + subject: 'UsersAcl', + // No conditions = can replace any user's relationships + // No fields = can replace all relationships +} + +// Result: Can replace any relationship for any user +// PATCH /users-acl/1/relationships/aclComments → ✅ Success (200) +// PATCH /users-acl/1/relationships/posts → ✅ Success (200) +// PATCH /users-acl/2/relationships/aclComments → ✅ Success (200) +``` + +**2. No conditions, with field restrictions (moderator)** + +```typescript +// Rule: +{ + action: 'patchRelationship', + subject: 'UsersAcl', + fields: ['posts'], // Only 'posts' relationship allowed +} + +// Scenario A: Replace allowed relationship +// PATCH /users-acl/1/relationships/posts +// body: { data: [{ type: 'posts', id: 5 }, { type: 'posts', id: 7 }] } +// → Fetch user with OLD posts: { id: 1, posts: [{ id: 2, ... }, { id: 3, ... }] } +// → Load NEW posts: [{ id: 5, ... }, { id: 7, ... }] +// → Create entityToCheck with __current +// → updateWithInput(entityToCheck) → Check: can('patchRelationship', user, 'posts') +// → ✅ 'posts' in fields → Success (200) + +// Scenario B: Replace forbidden relationship +// PATCH /users-acl/1/relationships/aclComments +// body: { data: [{ type: 'comments', id: 10 }] } +// → Fetch user with OLD aclComments → Load NEW comment → Create entityToCheck +// → updateWithInput(entityToCheck) → Check: can('patchRelationship', user, 'aclComments') +// → ❌ 'aclComments' NOT in fields → 403 Forbidden +// { +// "errors": [{ +// "code": "forbidden", +// "message": "not allow \"patchRelationship\"", +// "path": ["action"] +// }] +// } +``` + +**3. With conditions + field restrictions (user)** + +```typescript +// Rule: Can only replace own aclComments +{ + action: 'patchRelationship', + subject: 'UsersAcl', + conditions: { + id: '${currentUser.id}' // Must be own user + }, + fields: ['aclComments'] // Only this relationship +} + +// Scenario A: Replace own aclComments +// PATCH /users-acl/5/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 30 }, { type: 'comments', id: 40 }] } +// → Database query: WHERE id = 5 AND id = 5 (conditions match) +// → Fetch user with OLD aclComments: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] } +// → Load NEW aclComments: [{ id: 30, ... }, { id: 40, ... }] +// → Create entityToCheck: { +// id: 5, +// aclComments: [{ id: 30, ... }, { id: 40, ... }], +// __current: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] } +// } +// → updateWithInput(entityToCheck) +// → Check: can('patchRelationship', user, 'aclComments') +// → ✅ conditions match + 'aclComments' in fields → Success (200) + +// Scenario B: Try to replace someone else's aclComments +// PATCH /users-acl/10/relationships/aclComments (currentUser.id = 5) +// body: { data: [{ type: 'comments', id: 30 }] } +// → Database query: WHERE id = 10 AND id = 5 (conditions don't match) +// → Entity not found (filtered by ACL conditions) +// → ❌ 403 Forbidden + +// Scenario C: Try to replace forbidden relationship +// PATCH /users-acl/5/relationships/posts (currentUser.id = 5) +// body: { data: [{ type: 'posts', id: 7 }] } +// → Fetch user → Load posts → Create entityToCheck +// → Check: can('patchRelationship', user, 'posts') +// → ❌ 'posts' NOT in fields → 403 Forbidden +``` + +**Advanced: Using `__current` to compare old vs new** + +The `__current` magic enables powerful rules based on comparing old and new relationship values: + +```typescript +// Rule: User can ONLY add new items to aclComments, cannot remove existing ones +{ + action: 'patchRelationship', + subject: 'UsersAcl', + conditions: { + id: '${currentUser.id}', + 'aclComments': { + // NEW comments must include ALL old comment IDs + $all: '${@input.__current.aclComments.map(i => i.id)}' + } + }, + fields: ['aclComments'] +} + +// Scenario A: Add new comments (keeping all old ones) ✅ +// PATCH /users-acl/5/relationships/aclComments (currentUser.id = 5) +// body: { data: [ +// { type: 'comments', id: 10 }, // OLD +// { type: 'comments', id: 20 }, // OLD +// { type: 'comments', id: 30 }, // NEW +// { type: 'comments', id: 40 } // NEW +// ]} +// → OLD: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] } +// → NEW: [{ id: 10, ... }, { id: 20, ... }, { id: 30, ... }, { id: 40, ... }] +// → entityToCheck: { +// id: 5, +// aclComments: [{ id: 10 }, { id: 20 }, { id: 30 }, { id: 40 }], +// __current: { id: 5, aclComments: [{ id: 10 }, { id: 20 }] } +// } +// → Check: NEW comments [10, 20, 30, 40] include ALL old [10, 20] ✅ +// → Success (200) + +// Scenario B: Try to remove comment ❌ +// PATCH /users-acl/5/relationships/aclComments (currentUser.id = 5) +// body: { data: [ +// { type: 'comments', id: 20 }, // Only keeping id: 20, removing id: 10 +// { type: 'comments', id: 30 } // NEW +// ]} +// → OLD: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] } +// → NEW: [{ id: 20, ... }, { id: 30, ... }] +// → entityToCheck: { +// id: 5, +// aclComments: [{ id: 20 }, { id: 30 }], +// __current: { id: 5, aclComments: [{ id: 10 }, { id: 20 }] } +// } +// → Check: NEW comments [20, 30] do NOT include all old [10, 20] ❌ +// → 403 Forbidden (missing id: 10) + +// Scenario C: Replace completely (different items) ❌ +// PATCH /users-acl/5/relationships/aclComments (currentUser.id = 5) +// body: { data: [ +// { type: 'comments', id: 30 }, +// { type: 'comments', id: 40 } +// ]} +// → OLD: { id: 5, aclComments: [{ id: 10, ... }, { id: 20, ... }] } +// → NEW: [{ id: 30, ... }, { id: 40, ... }] +// → Check: NEW [30, 40] do NOT include old [10, 20] ❌ +// → 403 Forbidden +``` + +**Another example: Only allow removing your own comments** + +```typescript +// Rule: Can replace aclComments, but removed items must be authored by current user +{ + action: 'patchRelationship', + subject: 'UsersAcl', + conditions: { + id: '${currentUser.id}', + // Items in OLD but not in NEW must have authorId = currentUser.id + '__current.aclComments': { + $all: { + $or: [ + // Either: item is kept (exists in NEW) + { id: { $in: '${@input.aclComments.map(i => i.id)}' } }, + // Or: item is removed but authored by current user + { authorId: '${currentUser.id}' } + ] + } + } + }, + fields: ['aclComments'] +} +``` + +**Key Points:** + +- ✅ **Two-stage check**: Entity-level (fetch with conditions) + field-level (relationship name) +- ✅ **@input support**: Can use `${@input.field}` in conditions (entity + new relationships available) +- ✅ **`__current` support**: Can use `${@input.__current.*}` to access old relationship values +- ✅ **Loaded relationships**: New relationships loaded via `loadRelations` before ACL check +- ✅ **Field = Relationship name**: `fields` array contains allowed relationship names +- ✅ **Compare old vs new**: `__current` enables comparing what relationships were vs will be +- ✅ **Helper functions**: Can use helper functions from `AclRulesLoader.getHelpers()` to process old/new values +- ⚠️ **To-many vs to-one**: Works for both relationship types + +**How `__current` works:** + +```typescript +// Structure of entityToCheck: +{ + ...oldEntity, // Entity properties + [relName]: newRelationships, // NEW relationships at root level + __current: oldEntity // Complete OLD entity with OLD relationships +} + +// Example with to-many relationship: +{ + id: 5, + name: 'Alice', + aclComments: [{ id: 30, ... }, { id: 40, ... }], // NEW - what will be after patch + __current: { + id: 5, + name: 'Alice', + aclComments: [{ id: 10, ... }, { id: 20, ... }] // OLD - what was before patch + } +} + +// Rules can access: +// - ${@input.aclComments.map(i => i.id)} → [30, 40] (NEW values) +// - ${@input.__current.aclComments.map(i => i.id)} → [10, 20] (OLD values) +// - Compare, calculate diff, check if items removed/added, etc. +``` + +**404 Not Found vs 403 Forbidden:** + +```typescript +// Scenario 1: Entity doesn't exist +PATCH /posts/99999/relationships/comments +→ 404 Not Found (entity not found) + +// Scenario 2: Entity exists but ACL conditions deny access +PATCH /posts/5/relationships/comments (conditions don't match) +→ 403 Forbidden (filtered by ACL conditions) + +// Scenario 3: Entity exists, conditions match, but relationship not allowed +PATCH /posts/5/relationships/comments (entity accessible, but 'comments' not in fields) +→ 403 Forbidden (relationship not in fields list) + +// Scenario 4: Entity exists, relationship allowed, but __current conditions fail +PATCH /posts/5/relationships/comments (trying to remove items not authored by user) +→ 403 Forbidden (__current validation failed) + +// Why 403 instead of 404? +// - Consistent with other relationship methods +// - Don't leak information about resource existence +``` + +**Use cases:** + +1. **Simple field restriction**: "Users can only replace posts relationships, not comments" +2. **Owner-only replacement**: "Users can only replace relationships on their own entities" +3. **Add-only replacement**: "Users can add new items but cannot remove existing ones" +4. **Conditional removal**: "Users can remove only items they authored" +5. **Hybrid**: "Moderators can replace freely, users can only add to their own" +6. **Complex validation**: "Can replace if new list size >= old list size" (using helper functions) + +**Important:** patchRelationship uses the same error handling as getAll: +- Invalid ACL rules → Production: 403, Development: 500 +- Same recommendations apply (test rules, use simple conditions, monitor logs) + +--- diff --git a/libs/acl-permissions/nestjs-acl-permissions/package.json b/libs/acl-permissions/nestjs-acl-permissions/package.json new file mode 100644 index 00000000..e3685ad4 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/package.json @@ -0,0 +1,13 @@ +{ + "name": "@klerick/acl-json-api-nestjs", + "version": "0.0.1", + "dependencies": { + "@casl/ability": "^6.0.0" + }, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts", + "peerDependencies": { + "@klerick/json-api-nestjs": "0.0.0" + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/project.json b/libs/acl-permissions/nestjs-acl-permissions/project.json new file mode 100644 index 00000000..35cff2d8 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/project.json @@ -0,0 +1,67 @@ +{ + "name": "nestjs-acl-permissions", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/acl-permissions/nestjs-acl-permissions", + "projectType": "library", + "implicitDependencies": ["json-api-nestjs"], + "targets": { + "build-common": { + "dependsOn": ["^build", "^build-cjs", "^build-mjs"], + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/{projectRoot}", + "tsConfig": "{projectRoot}/tsconfig.lib.json", + "packageJson": "{projectRoot}/package.json", + "main": "{projectRoot}/src/index.ts", + "assets": ["{projectRoot}/*.md"], + "generateExportsField": true, + "updateBuildableProjectDepsInPackageJson": false + } + }, + "build": { + "executor": "nx:run-commands", + "dependsOn": [ + "build-common" + ], + "options": { + "outputPath": "dist/{projectRoot}", + "commands": [ + { + "command": "node tools/scripts/prepare-package-json.mjs nestjs-acl-permissions" + }, + { + "command": "mkdir -p node_modules/@klerick && rm -rf node_modules/@klerick/acl-json-api-nestjs", + "forwardAllArgs": false + }, + { + "command": "ln -s $(pwd)/dist/{projectRoot} node_modules/@klerick/acl-json-api-nestjs", + "forwardAllArgs": false + } + ], + "cwd": "./", + "parallel": false + } + }, + "build1": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/acl-permissions/nestjs-acl-permissions", + "tsConfig": "libs/acl-permissions/nestjs-acl-permissions/tsconfig.lib.json", + "packageJson": "libs/acl-permissions/nestjs-acl-permissions/package.json", + "main": "libs/acl-permissions/nestjs-acl-permissions/src/index.ts", + "assets": ["libs/acl-permissions/nestjs-acl-permissions/*.md"] + } + }, + "publish": { + "command": "node tools/scripts/publish.mjs nestjs-acl-permissions {args.ver} {args.tag}", + "dependsOn": ["build"] + } + }, + "tags": [ + "type:lib", + "lib:nestjs-acl-permissions", + "type:publish" + ] +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/index.ts new file mode 100644 index 00000000..6222aa63 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/index.ts @@ -0,0 +1,11 @@ +/** + * NestJS ACL Permissions Module + * CASL-based access control for JSON API controllers + */ + +// Main module +export { AclPermissionsModule } from './lib/nestjs-acl-permissions.module'; + +export { AclAction, AclRule, AclRulesLoader, AclSubject } from './lib/types'; +export { ExtendAbility } from './lib/factories'; +export { wrapperJsonApiController } from './lib/wrappers'; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/constants/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/constants/index.ts new file mode 100644 index 00000000..967c4a8a --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/constants/index.ts @@ -0,0 +1,17 @@ +/** + * Token for injecting ACL module options + */ +export const ACL_MODULE_OPTIONS = Symbol('ACL_MODULE_OPTIONS'); + +/** + * Metadata key for @AclController decorator + */ +export const ACL_CONTROLLER_METADATA = Symbol('ACL_CONTROLLER_METADATA'); +/** + * Key for storing ACL data in context + */ +export const ACL_CONTEXT_KEY = Symbol('ACL_CONTEXT_KEY'); + + +export const MODULE_REF_PROPS = Symbol('MODULE_REF_PROPS'); +export const ORIGINAL_ORM_SERVICE = Symbol('ORIGINAL_ORM_SERVICE'); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/decorators/acl-controller.decorator.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/decorators/acl-controller.decorator.ts new file mode 100644 index 00000000..add9564d --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/decorators/acl-controller.decorator.ts @@ -0,0 +1,26 @@ +import type { AnyEntity } from '@klerick/json-api-nestjs-shared'; +import type { JsonBaseController } from '@klerick/json-api-nestjs'; +import { UseGuards } from '@nestjs/common'; +import { ACL_CONTROLLER_METADATA } from '../constants'; +import type { AclControllerOptions, AclControllerMetadata, AclControllerMethodsOptions } from '../types'; + +import { AclGuard } from '../guards'; + +export function AclController< + E extends AnyEntity = AnyEntity, + Controller = JsonBaseController +>(options: AclControllerOptions) { + return function any>( + target: T + ): T { + const metadata: AclControllerMetadata = { + subject: options.subject, + methods: (options.methods || {}) as Record, + enabled: options.enabled ?? true, + }; + + Reflect.defineMetadata(ACL_CONTROLLER_METADATA, metadata, target); + UseGuards(AclGuard)(target); + return target; + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/decorators/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/decorators/index.ts new file mode 100644 index 00000000..3b34159c --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/decorators/index.ts @@ -0,0 +1 @@ +export { AclController } from './acl-controller.decorator'; \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability-proxy.factory.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability-proxy.factory.ts new file mode 100644 index 00000000..2bf91836 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability-proxy.factory.ts @@ -0,0 +1,121 @@ +import { ModuleRef } from '@nestjs/core'; +import type { AclContextStore, AclModuleOptions } from '../types'; +import { ExtendAbility } from './ability.factory'; +import { ACL_CONTEXT_KEY, ACL_MODULE_OPTIONS } from '../constants'; +import { FactoryProvider } from '@nestjs/common'; + + +/** + * Creates a Proxy for ExtendableAbility that lazily retrieves the actual ability + * from CLS (Continuation Local Storage) on each method/property access. + * + * This allows the provider to be a SINGLETON while still accessing request-specific + * ability instances without using Scope.REQUEST. + * + * @param moduleRef - NestJS ModuleRef for lazy dependency resolution + * @param options - ACL module options containing contextStore configuration + * @returns Proxy that acts as ExtendableAbility + */ +export function createAbilityProxy( + moduleRef: ModuleRef, + options: AclModuleOptions +): ExtendAbility { + let contextStore: AclContextStore | undefined; + + /** + * Lazily initializes and retrieves the context store + */ + const getContextStore = (): AclContextStore | undefined => { + if (!contextStore && options.contextStore) { + contextStore = moduleRef.get(options.contextStore, { + strict: false, + }); + } + return contextStore; + }; + + /** + * Retrieves ExtendableAbility from CLS for the current request + * Throws descriptive error if ability is not found + */ + const getAbility = (): ExtendAbility => { + const store = getContextStore(); + if (!store) { + throw new Error( + '[ACL] contextStore is not configured. ' + + 'Make sure you configured AclPermissionsModule.forRoot() with contextStore option. ' + + 'Example: { contextStore: ClsService }' + ); + } + + const ability = store.get(ACL_CONTEXT_KEY); + + if (!ability) { + throw new Error( + '[ACL] ExtendAbility not found in context store. ' + + 'Possible causes:\n' + + ' 1. Service is called BEFORE AclGuard executed (ability not yet created)\n' + + ' 2. No rules loaded for this action (check your RulesLoader.loadRules())\n' + + ' 3. AclGuard was not applied to this controller (check @AclController or wrapperJsonApiController hook)\n' + + ' 4. contextStore middleware is not mounted (check ClsModule configuration)' + ); + } + + return ability; + }; + + // Create Proxy that forwards all method/property access to the real ExtendableAbility + return new Proxy({} as ExtendAbility, { + get(target, prop: string | symbol) { + if (!Reflect.has(ExtendAbility.prototype, prop)) { + return undefined; + } + + // Special handling for Symbol.toStringTag (used by Object.prototype.toString) + if (prop === Symbol.toStringTag) { + return 'ExtendableAbility'; + } + + // Special handling for typeof checks + if (prop === 'constructor') { + return ExtendAbility; + } + + // Get the actual ability from CLS + const ability = getAbility(); + // Get the property/method from the real ability + const value = Reflect.get(ability, prop, ability); + + // If it's a function, bind it to the ability instance + if (typeof value === 'function') { + return value.bind(ability); + } + + return value; + }, + + // Support for 'prop in ability' checks + has(target, prop) { + const ability = getAbility(); + return Reflect.has(ability, prop); + }, + + // Support for Object.keys(), Object.getOwnPropertyNames(), etc. + ownKeys(target) { + const ability = getAbility(); + return Reflect.ownKeys(ability); + }, + + // Support for Object.getOwnPropertyDescriptor() + getOwnPropertyDescriptor(target, prop) { + const ability = getAbility(); + return Reflect.getOwnPropertyDescriptor(ability, prop); + }, + }); +} + +export const AbilityProvider: FactoryProvider = { + provide: ExtendAbility, + useFactory: createAbilityProxy, + inject: [ModuleRef, ACL_MODULE_OPTIONS], +}; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability.factory.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability.factory.spec.ts new file mode 100644 index 00000000..2f880329 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability.factory.spec.ts @@ -0,0 +1,863 @@ +import { ExtendAbility } from './ability.factory'; +import { RuleMaterializer } from '../services'; + +const mockLoggerDebug = vi.fn(); +const mockLoggerWarn = vi.fn(); +const mockLoggerError = vi.fn(); + +vi.mock('@nestjs/common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Logger: class Logger { + debug = mockLoggerDebug; + warn = mockLoggerWarn; + error = mockLoggerError; + }, + }; +}); + +describe('ExtendAbility', () => { + const subject = 'Users'; + const action = 'getAll'; + const materializeMock = vi.fn(); + const materialize = new RuleMaterializer(); + const currentUserId = 1; + const userId = 2; + const rules = [ + { + action, + subject, + conditions: { authorId: '${currentUserId}' }, // Static + }, + { + action, + subject, + conditions: { userId: '${@input.userId}' }, // Dynamic + }, + ] as any; + + it('Check Ability after init without input rules', () => { + materializeMock.mockReturnValueOnce(rules); + const extendAbility = new ExtendAbility( + materialize, + subject, + action, + rules, + { currentUserId }, + {} + ); + expect(extendAbility.rules).toEqual([ + { action, subject, conditions: { authorId: currentUserId } }, + ]); + }); + + it('Check Ability after call updateWithInput', () => { + materializeMock.mockReturnValueOnce(rules); + const extendAbility = new ExtendAbility( + materialize, + subject, + action, + rules, + { currentUserId }, + {} + ); + extendAbility.updateWithInput({ userId }); + + expect(extendAbility.rules).toEqual([ + { action, subject, conditions: { authorId: currentUserId } }, + { + action, + subject, + conditions: { userId: userId }, + }, + ]); + }); + + describe('getQueryObject', () => { + it('should return empty object when no conditions', () => { + const rulesWithoutConditions = [ + { + action, + subject, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithoutConditions, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result).toEqual({}); + }); + + it('should extract target fields from simple conditions', () => { + const simpleRules = [ + { + action, + subject, + conditions: { authorId: 123, status: 'published' }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + simpleRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + target: expect.arrayContaining(['authorId', 'status']), + }); + // CASL wraps single rules in $or + expect(result.rulesForQuery).toHaveProperty('$or'); + expect((result.rulesForQuery as any).$or[0]).toEqual({ + authorId: 123, + status: 'published', + }); + }); + + it('should extract relation fields and include', () => { + const relationRules = [ + { + action, + subject, + conditions: { + authorId: 123, + 'profile.isPublic': true, + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + relationRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + target: ['authorId'], + profile: ['isPublic'], + }); + expect(result.include).toEqual(['profile']); + // CASL wraps single rules in $or + expect(result.rulesForQuery).toHaveProperty('$or'); + expect((result.rulesForQuery as any).$or[0]).toEqual({ + authorId: 123, + profile: { isPublic: true }, + }); + }); + + it('should handle MongoDB operators', () => { + const operatorRules = [ + { + action, + subject, + conditions: { + age: { $gte: 18, $lte: 65 }, + status: { $in: ['published', 'archived'] }, + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + operatorRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + target: expect.arrayContaining(['age', 'status']), + }); + // CASL wraps single rules in $or + expect(result.rulesForQuery).toHaveProperty('$or'); + expect((result.rulesForQuery as any).$or[0]).toEqual({ + age: { $gte: 18, $lte: 65 }, + status: { $in: ['published', 'archived'] }, + }); + }); + + it('should handle multiple rules with $or operator', () => { + const multipleRules = [ + { + action, + subject, + conditions: { authorId: 123 }, + }, + { + action, + subject, + conditions: { 'profile.isPublic': true }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + multipleRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + target: ['authorId'], + profile: ['isPublic'], + }); + expect(result.include).toEqual(['profile']); + expect(result.rulesForQuery).toHaveProperty('$or'); + // Order may vary + expect((result.rulesForQuery as any).$or).toEqual( + expect.arrayContaining([ + { authorId: 123 }, + { profile: { isPublic: true } }, + ]) + ); + }); + + it('should handle inverted rules (cannot)', () => { + const invertedRules = [ + { + action, + subject, + conditions: { authorId: 123 }, + }, + { + action, + subject, + conditions: { status: 'draft' }, + inverted: true, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + invertedRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + target: expect.arrayContaining(['authorId', 'status']), + }); + expect(result.rulesForQuery).toHaveProperty('$and'); + }); + + it('should extract fields from nested $or operator', () => { + const nestedRules = [ + { + action, + subject, + conditions: { + $or: [ + { authorId: 123 }, + { 'profile.isPublic': true }, + { status: 'published' }, + ], + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + nestedRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + target: expect.arrayContaining(['authorId', 'status']), + profile: ['isPublic'], + }); + expect(result.include).toEqual(['profile']); + expect(result.rulesForQuery).toHaveProperty('$or'); + }); + + it('should handle complex nested operators', () => { + const complexRules = [ + { + action, + subject, + conditions: { + $and: [ + { + $or: [{ authorId: 123 }, { 'profile.role': 'admin' }], + }, + { status: 'published' }, + ], + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + complexRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + target: expect.arrayContaining(['authorId', 'status']), + profile: ['role'], + }); + expect(result.include).toEqual(['profile']); + // CASL wraps single rules in $or, with nested $and + expect(result.rulesForQuery).toHaveProperty('$or'); + expect((result.rulesForQuery as any).$or[0]).toHaveProperty('$and'); + }); + + it('should handle multiple relations', () => { + const multiRelationRules = [ + { + action, + subject, + conditions: { + 'profile.isPublic': true, + 'comments.approved': true, + authorId: 123, + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + multiRelationRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + target: ['authorId'], + profile: ['isPublic'], + comments: ['approved'], + }); + expect(result.include).toEqual( + expect.arrayContaining(['profile', 'comments']) + ); + // CASL wraps single rules in $or + expect(result.rulesForQuery).toHaveProperty('$or'); + expect((result.rulesForQuery as any).$or[0]).toEqual({ + authorId: 123, + profile: { isPublic: true }, + comments: { approved: true }, + }); + }); + + it('should transform relation fields correctly', () => { + const transformRules = [ + { + action, + subject, + conditions: { + 'profile.name': 'John', + 'profile.age': { $gte: 18 }, + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + transformRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields).toEqual({ + profile: expect.arrayContaining(['name', 'age']), + }); + expect(result.include).toEqual(['profile']); + // CASL wraps single rules in $or + expect(result.rulesForQuery).toHaveProperty('$or'); + expect((result.rulesForQuery as any).$or[0]).toEqual({ + profile: { + name: 'John', + age: { $gte: 18 }, + }, + }); + }); + + it('should not return rulesForQuery when it is empty', () => { + const emptyConditionsRules = [ + { + action, + subject, + conditions: {}, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + emptyConditionsRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + // CASL may return {$or: [{}]} for empty conditions, we filter it out + // So result should not have rulesForQuery or it should be empty + if (result.rulesForQuery) { + // If it exists, check it's empty or has only empty objects + const keys = Object.keys(result.rulesForQuery); + expect(keys.length).toBe(0); + } + }); + + it('should deduplicate fields from multiple conditions', () => { + const duplicateRules = [ + { + action, + subject, + conditions: { + authorId: 123, + status: 'published', + }, + }, + { + action, + subject, + conditions: { + authorId: 456, + category: 'news', + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + duplicateRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.fields?.target).toEqual( + expect.arrayContaining(['authorId', 'status', 'category']) + ); + // Should not have duplicates + expect(result.fields?.target?.length).toBe(3); + }); + }); + + describe('hasConditions and hasFields getters', () => { + it('should return false when no conditions and no fields', () => { + const rulesNoConditionsNoFields = [ + { + action, + subject, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesNoConditionsNoFields, + {}, + {} + ); + + expect(ability.hasConditions).toBe(false); + expect(ability.hasFields).toBe(false); + }); + + it('should return true for hasConditions when conditions exist', () => { + const rulesWithConditions = [ + { + action, + subject, + conditions: { authorId: 123 }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithConditions, + {}, + {} + ); + + expect(ability.hasConditions).toBe(true); + expect(ability.hasFields).toBe(false); + }); + + it('should return true for hasFields when fields exist', () => { + const rulesWithFields = [ + { + action, + subject, + fields: ['id', 'name'], + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithFields, + {}, + {} + ); + + expect(ability.hasConditions).toBe(false); + expect(ability.hasFields).toBe(true); + }); + + it('should return true for both when conditions and fields exist', () => { + const rulesWithBoth = [ + { + action, + subject, + conditions: { authorId: 123 }, + fields: ['id', 'name'], + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithBoth, + {}, + {} + ); + + expect(ability.hasConditions).toBe(true); + expect(ability.hasFields).toBe(true); + }); + + it('should return correct values with multiple rules', () => { + const mixedRules = [ + { + action, + subject, + conditions: { authorId: 123 }, + }, + { + action, + subject, + fields: ['id', 'name'], + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + mixedRules, + {}, + {} + ); + + expect(ability.hasConditions).toBe(true); + expect(ability.hasFields).toBe(true); + }); + }); + + describe('MikroORM operator transformation', () => { + it('should transform $regex to $re in field values', () => { + const rulesWithRegex = [ + { + action, + subject, + conditions: { + name: { $regex: '^John' }, + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithRegex, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.rulesForQuery).toEqual({ + $or: [ + { + name: { $re: '^John' }, + }, + ], + }); + }); + + it('should transform $all to $contains in field values', () => { + const rulesWithAll = [ + { + action, + subject, + conditions: { + tags: { $all: ['admin', 'moderator'] }, + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithAll, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.rulesForQuery).toEqual({ + $or: [ + { + tags: { $contains: ['admin', 'moderator'] }, + }, + ], + }); + }); + + it('should transform $nor to $not: { $or: [...] }', () => { + const rulesWithNor = [ + { + action, + subject, + conditions: { + $nor: [{ status: 'banned' }, { status: 'deleted' }], + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithNor, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.rulesForQuery).toEqual({ + $or: [ + { + $not: { + $or: [{ status: 'banned' }, { status: 'deleted' }], + }, + }, + ], + }); + }); + + it('should transform operators in nested $or/$and', () => { + const rulesWithNestedOperators = [ + { + action, + subject, + conditions: { + $or: [ + { name: { $regex: '^John' } }, + { tags: { $all: ['admin'] } }, + ], + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithNestedOperators, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.rulesForQuery).toEqual({ + $or: [ + { + $or: [ + { name: { $re: '^John' } }, + { tags: { $contains: ['admin'] } }, + ], + }, + ], + }); + }); + + it('should transform operators in relation fields', () => { + const rulesWithRelationOperators = [ + { + action, + subject, + conditions: { + 'profile.name': { $regex: '^John' }, + 'profile.tags': { $all: ['verified'] }, + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithRelationOperators, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.rulesForQuery).toEqual({ + $or: [ + { + profile: { + name: { $re: '^John' }, + tags: { $contains: ['verified'] }, + }, + }, + ], + }); + expect(result.fields?.profile).toEqual(['name', 'tags']); + expect(result.include).toEqual(['profile']); + }); + + it('should handle multiple transformations in complex query', () => { + const complexRules = [ + { + action, + subject, + conditions: { + $or: [ + { name: { $regex: '^Admin' } }, + { + $and: [ + { tags: { $all: ['moderator'] } }, + { $nor: [{ status: 'banned' }] }, + ], + }, + ], + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + complexRules, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.rulesForQuery).toEqual({ + $or: [ + { + $or: [ + { name: { $re: '^Admin' } }, + { + $and: [ + { tags: { $contains: ['moderator'] } }, + { + $not: { + $or: [{ status: 'banned' }], + }, + }, + ], + }, + ], + }, + ], + }); + }); + + it('should preserve other operators unchanged', () => { + const rulesWithStandardOperators = [ + { + action, + subject, + conditions: { + age: { $gte: 18, $lte: 65 }, + role: { $in: ['admin', 'moderator'] }, + status: { $ne: 'deleted' }, + }, + }, + ] as any; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithStandardOperators, + {}, + {} + ); + + const result = ability.getQueryObject(); + + expect(result.rulesForQuery).toEqual({ + $or: [ + { + age: { $gte: 18, $lte: 65 }, + role: { $in: ['admin', 'moderator'] }, + status: { $ne: 'deleted' }, + }, + ], + }); + }); + }); +}); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability.factory.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability.factory.ts new file mode 100644 index 00000000..84f9c005 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/ability.factory.ts @@ -0,0 +1,352 @@ +import { FactoryProvider } from '@nestjs/common'; +import { + AbilityTuple, + fieldPatternMatcher, + MongoQuery, + mongoQueryMatcher, + PureAbility, + RawRuleFrom, +} from '@casl/ability'; +import { rulesToQuery } from '@casl/ability/extra'; +import { RuleMaterializer } from '../services'; + +import { AclInputData, AclRule, ACL_INPUT_TEMPLATE } from '../types'; +import { ModuleRef } from '@nestjs/core'; +import { Query, QueryOne } from '@klerick/json-api-nestjs'; +import { QueryField } from '@klerick/json-api-nestjs-shared'; + +export type AbilityFactory = ( + subject: string, + action: string, + rules: AclRule[], + context: Record, + helpers: Record unknown> +) => ExtendAbility; + +export const ABILITY_FACTORY = Symbol('ABILITY_FACTORY'); +export const AbilityFactoryProvider: FactoryProvider = { + provide: ABILITY_FACTORY, + useFactory: function AbilityFactory(moduleRef: ModuleRef) { + return function createEmptyAbility< + A extends AbilityTuple = AbilityTuple, + C extends MongoQuery = MongoQuery + >( + subject: string, + action: string, + rules: RawRuleFrom[], + context: Record, + helpers: Record unknown> + ) { + return new ExtendAbility( + moduleRef.get(RuleMaterializer), + subject, + action, + rules, + context, + helpers + ); + }; + }, + inject: [ModuleRef], +}; + +export class ExtendAbility< + A extends AbilityTuple = AbilityTuple, + C extends MongoQuery = MongoQuery +> extends PureAbility { + private _hasFields: undefined | boolean = undefined; + private _hasConditions: undefined | boolean = undefined; + + constructor( + private ruleMaterializer: RuleMaterializer, + private currentSubject: string, + private currentAction: string, + private allRules: RawRuleFrom[], + + private readonly context: Record, + private readonly helpers: Record unknown> + ) { + const { withoutInput } = ExtendAbility.splitRulesByInput(allRules); + + const materialized = ruleMaterializer.materialize( + withoutInput, + context, + helpers, + undefined // NO @input + ); + + super(materialized, { + conditionsMatcher: mongoQueryMatcher, + fieldMatcher: fieldPatternMatcher, + }); + } + + updateWithInput(input: AclInputData) { + const materialized = this.ruleMaterializer.materialize( + this.allRules, + this.context, + this.helpers, + input + ); + + this.update(materialized); + } + + get hasConditions(): boolean { + if (this._hasConditions === undefined) { + this.hasConditionsAndFields(); + } + return this._hasConditions as boolean; + } + + get hasFields(): boolean { + if (this._hasFields === undefined) { + this.hasConditionsAndFields(); + } + return this._hasFields as boolean; + } + + get action(): string { + return this.currentAction; + } + + get subject(): string { + return this.currentSubject; + } + + hasConditionsAndFields(): boolean { + const rules = this.allRules; + for (let i = 0; i < rules.length; i++) { + if (rules[i].conditions !== undefined) { + this._hasConditions = true; + } + if (rules[i].fields !== undefined) { + this._hasFields = true; + } + if (this._hasConditions === true && this._hasFields === true) + return false; + } + + // Set false if not found + if (this._hasConditions === undefined) { + this._hasConditions = false; + } + if (this._hasFields === undefined) { + this._hasFields = false; + } + + return true; + } + + getQueryObject< + E extends object, + IdKey extends string, + Q extends QueryOne | Query + >(): { + fields?: Q[QueryField.fields]; + include?: Q[QueryField.include]; + rulesForQuery?: Record; + } { + const fieldMap = new Map>(); + const includeSet = new Set(); + + const rulesForQuery = rulesToQuery( + this, + this.currentAction, + this.currentSubject as any, + (rule) => { + const { conditions, inverted } = rule; + + if (!conditions) return {}; + + const transformed = this.processConditionsForQuery( + conditions, + fieldMap, + includeSet + ); + + return inverted ? { $not: transformed } : transformed; + } + ) as Record; + + // Build fields as object (not array) + let fields: Query['fields'] | undefined = undefined; + let include: Query['include'] | undefined = undefined; + + if (fieldMap.size > 0) { + fields = {} as any; + Array.from(fieldMap.entries()).forEach(([key, valueSet]) => { + (fields as any)[key] = Array.from(valueSet); + }); + } + + if (includeSet.size > 0) { + include = Array.from(includeSet) as any; + } + + return { + ...(fields && { fields }), + ...(include && { include }), + // Return undefined if rulesForQuery is null or empty object + ...(rulesForQuery && + !this.isEmptyQuery(rulesForQuery) && { + rulesForQuery, + }), + }; + } + + /** + * Process conditions recursively: extract fields, transform structure, transform operators for MikroORM + */ + private processConditionsForQuery( + conditions: Record, + fieldMap: Map>, + includeSet: Set + ): Record { + const result: Record = {}; + + for (const [fieldPath, value] of Object.entries(conditions)) { + // MongoDB operators at top level ($or, $and, $nor, etc.) + if (fieldPath.startsWith('$')) { + // Transform $nor → $not: { $or: [...] } + if (fieldPath === '$nor' && Array.isArray(value)) { + result['$not'] = { + $or: value.map((subCondition) => + this.processConditionsForQuery(subCondition, fieldMap, includeSet) + ), + }; + continue; + } + + // For $or/$and, recursively process + if ((fieldPath === '$or' || fieldPath === '$and') && Array.isArray(value)) { + result[fieldPath] = value.map((subCondition) => + this.processConditionsForQuery(subCondition, fieldMap, includeSet) + ); + continue; + } + + // Keep other operators as is + result[fieldPath] = value; + continue; + } + + // Parse field path + const [firstPart, secondPart] = fieldPath.split('.'); + + if (!secondPart) { + // Target entity field: 'authorId' + const targetSet = fieldMap.get('target') || new Set(); + targetSet.add(firstPart); + fieldMap.set('target', targetSet); + + // Transform operators in field values + result[firstPart] = this.transformOperatorsInValue(value); + } else { + // Relation field: 'profile.name' + const relationSet = fieldMap.get(firstPart) || new Set(); + relationSet.add(secondPart); + fieldMap.set(firstPart, relationSet); + includeSet.add(firstPart); + + // Transform: 'profile.name' → { profile: { name: value } } + // Merge multiple fields from same relation + if (!result[firstPart]) { + result[firstPart] = {}; + } + (result[firstPart] as Record)[secondPart] = + this.transformOperatorsInValue(value); + } + } + + return result; + } + + /** + * Transform CASL operators to MikroORM operators in field values + * $regex → $re, $all → $contains + */ + private transformOperatorsInValue(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => this.transformOperatorsInValue(item)); + } + + const result: Record = {}; + + for (const [key, val] of Object.entries(value)) { + // Transform $regex → $re + if (key === '$regex') { + result['$re'] = val; + continue; + } + + // Transform $all → $contains + if (key === '$all') { + result['$contains'] = val; + continue; + } + + // Recursively process other values + result[key] = this.transformOperatorsInValue(val); + } + + return result; + } + + /** + * Check if MongoDB query is empty + */ + private isEmptyQuery(query: Record): boolean { + const keys = Object.keys(query); + + if (keys.length === 0) return true; + + // Check for {$or: [{}]} or {$and: [{}]} patterns + if (keys.length === 1 && (keys[0] === '$or' || keys[0] === '$and')) { + const value = query[keys[0]]; + if (Array.isArray(value)) { + return value.every( + (item) => + typeof item === 'object' && + item !== null && + Object.keys(item).length === 0 + ); + } + } + + return false; + } + + /** + * Splits rules into those with @input templates and without + * Rules with @input will be materialized lazily + */ + private static splitRulesByInput< + A extends AbilityTuple = AbilityTuple, + C extends MongoQuery = MongoQuery + >( + rules: RawRuleFrom[] + ): { + withoutInput: RawRuleFrom[]; + withInput: RawRuleFrom[]; + } { + const withoutInput: RawRuleFrom[] = []; + const withInput: RawRuleFrom[] = []; + + for (const rule of rules) { + const jsonStr = JSON.stringify(rule); + + if (jsonStr.includes(ACL_INPUT_TEMPLATE)) { + withInput.push(rule); + } else { + withoutInput.push(rule); + } + } + + return { withoutInput, withInput }; + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/index.ts new file mode 100644 index 00000000..406e655b --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factories/index.ts @@ -0,0 +1,2 @@ +export * from './ability.factory' +export * from './ability-proxy.factory' diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/acl.guard.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/acl.guard.spec.ts new file mode 100644 index 00000000..03990243 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/acl.guard.spec.ts @@ -0,0 +1,145 @@ +import { TestBed } from '@suites/unit'; +import { ModuleRef, Reflector } from '@nestjs/core'; + +import { AclGuard } from './acl.guard'; +import { ExecutionContext } from '@nestjs/common'; +import { expect, vi } from 'vitest'; +import { Mocked } from '@suites/doubles.vitest'; +import { AclAuthorizationService } from '../services'; + +const mockLoggerDebug = vi.fn(); +const mockLoggerWarn = vi.fn(); +const mockLoggerError = vi.fn(); + +vi.mock('@nestjs/common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Logger: class Logger { + debug = mockLoggerDebug; + warn = mockLoggerWarn; + error = mockLoggerError; + }, + }; +}); + +const createMockExecutionContext = ( + controller: any, + handler: any +): ExecutionContext => + ({ + getClass: () => controller, + getHandler: () => handler, + switchToHttp: vi.fn(), + } as unknown as ExecutionContext); + +class UsersController { + getAll() { + void 0; + } + getOne() { + void 0; + } + postOne() { + void 0; + } + patchOne() { + void 0; + } + deleteOne() { + void 0; + } +} + +describe('AclGuard', () => { + let aclGuard: AclGuard; + let moduleRef: Mocked; + let reflector: Mocked; + + beforeEach(async () => { + const { unit, unitRef } = await TestBed.solitary(AclGuard).compile(); + // @ts-expect-error incorrect type + moduleRef = unitRef.get(ModuleRef); + reflector = unitRef.get(Reflector); + aclGuard = unit; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Return true when metadata is empty', async () => { + const executionContext = createMockExecutionContext( + UsersController, + UsersController.prototype.getAll + ); + + reflector.get.mockReturnValue(undefined); + const result = await aclGuard.canActivate(executionContext); + expect(result).toBe(true); + expect(mockLoggerDebug).toBeCalledWith( + `No @AclController metadata found on ${UsersController.name}, allowing access` + ); + expect(moduleRef.get).not.toBeCalled(); + }); + + it('Return true when metadata.enabled is false', async () => { + const executionContext = createMockExecutionContext( + UsersController, + UsersController.prototype.getAll + ); + + reflector.get.mockReturnValue({ enabled: false }); + const result = await aclGuard.canActivate(executionContext); + expect(result).toBe(true); + expect(mockLoggerDebug).toBeCalledWith( + `ACL disabled for controller ${UsersController.name}, allowing access` + ); + expect(moduleRef.get).not.toBeCalled(); + }); + + it('Return true when metadata.methods[methodName] is false', async () => { + const executionContext = createMockExecutionContext( + UsersController, + UsersController.prototype.getAll + ); + + reflector.get.mockReturnValue({ + enabled: true, + methods: { getAll: false }, + }); + const result = await aclGuard.canActivate(executionContext); + expect(result).toBe(true); + expect(mockLoggerDebug).toBeCalledWith( + `ACL disabled for method ${UsersController.name}.${UsersController.prototype.getAll.name}, allowing access` + ); + expect(moduleRef.get).not.toBeCalled(); + }); + + it('Should be call moduleRef.get with AclAuthorizationService and call authorize ', async () => { + const executionContext = createMockExecutionContext( + UsersController, + UsersController.prototype.getAll + ); + const metaData = { + enabled: true, + methods: { getAll: true }, + }; + reflector.get.mockReturnValue(metaData); + const authorizeSpy = vi.fn().mockResolvedValue(true); + moduleRef.get.mockReturnValue({ + authorize: authorizeSpy, + }); + + const result = await aclGuard.canActivate(executionContext); + expect(result).toBe(true); + expect(moduleRef.get).toBeCalledWith(AclAuthorizationService, { + strict: false, + }); + expect(authorizeSpy).toBeCalledWith( + UsersController.name, + UsersController.prototype.getAll.name, + metaData + ); + }); +}); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/acl.guard.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/acl.guard.ts new file mode 100644 index 00000000..38f518eb --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/acl.guard.ts @@ -0,0 +1,71 @@ +import { Injectable, CanActivate, ExecutionContext, Inject, Logger } from '@nestjs/common'; +import { ModuleRef, Reflector } from '@nestjs/core'; +import { AclAuthorizationService } from '../services'; +import { AclControllerMetadata } from '../types'; +import { ACL_CONTROLLER_METADATA } from '../constants'; + +/** + * Guard for ACL permission checking + * + * Thin wrapper that delegates all logic to AclAuthorizationService + * + * Applied automatically by @AclController decorator + * + * Lifecycle: Guards run BEFORE interceptors and pipes + * + * Benefits: + * - Fails fast (before handler execution) + * - Semantic correctness (Guard = authorization) + * - Supports onNoRules policy (deny/allow/warn) + */ +@Injectable() +export class AclGuard implements CanActivate { + @Inject(ModuleRef) private readonly moduleRef!: ModuleRef; + @Inject(Reflector) private readonly reflector!: Reflector; + + private readonly logger = new Logger(AclGuard.name); + + async canActivate(executionContext: ExecutionContext): Promise { + + const controller = executionContext.getClass(); + const handler = executionContext.getHandler(); + const controllerName = controller.name; + const methodName = handler.name; + + const metadata = this.reflector.get( + ACL_CONTROLLER_METADATA, + controller + ); + + if (!metadata) { + this.logger.debug( + `No @AclController metadata found on ${controllerName}, allowing access` + ); + return true; + } + + // If ACL is disabled for this controller + if (metadata.enabled === false) { + this.logger.debug( + `ACL disabled for controller ${controllerName}, allowing access` + ); + return true; + } + + // Check if ACL is enabled for this specific method + const isMethodEnabled = metadata.methods[methodName]; + + // If method configuration is explicitly false, allow access + if (isMethodEnabled === false) { + this.logger.debug( + `ACL disabled for method ${controllerName}.${methodName}, allowing access` + ); + return true; + } + + // Delegate all logic to authorization service + return this.moduleRef.get(AclAuthorizationService, { + strict: false, + }).authorize(controllerName, methodName, metadata); + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/index.ts new file mode 100644 index 00000000..91f6c254 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/guards/index.ts @@ -0,0 +1 @@ +export * from './acl.guard'; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/nestjs-acl-permissions.module.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/nestjs-acl-permissions.module.ts new file mode 100644 index 00000000..d21ca77b --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/nestjs-acl-permissions.module.ts @@ -0,0 +1,49 @@ +import { + Module, + DynamicModule, + Inject, + OnModuleInit, + Logger, +} from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { ACL_MODULE_OPTIONS } from './constants'; +import type { AclModuleOptions } from './types'; +import { AclAuthorizationService, RuleMaterializer } from './services'; +import { AclGuard } from './guards'; +import { AbilityFactoryProvider, AbilityProvider } from './factories'; + +@Module({}) +export class AclPermissionsModule implements OnModuleInit { + @Inject(ModuleRef) private readonly moduleRef!: ModuleRef; + @Inject(ACL_MODULE_OPTIONS) private readonly options!: AclModuleOptions; + private readonly logger = new Logger(AclPermissionsModule.name); + static forRoot(options: AclModuleOptions): DynamicModule { + return { + module: AclPermissionsModule, + providers: [ + { + provide: ACL_MODULE_OPTIONS, + useValue: options, + }, + AclGuard, + AclAuthorizationService, + RuleMaterializer, + AbilityFactoryProvider, + AbilityProvider, + ], + exports: [AclGuard, AclAuthorizationService, AbilityProvider], + }; + } + + onModuleInit() { + try { + this.moduleRef.get(this.options.rulesLoader, {strict: false}); + this.moduleRef.get(this.options.contextStore, {strict: false}); + } catch (error) { + this.logger.warn( + `RulesLoader or ContextStore not found, ACL will not work` + ); + throw error; + } + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/acl-authorization.service.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/acl-authorization.service.spec.ts new file mode 100644 index 00000000..ccaed4a6 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/acl-authorization.service.spec.ts @@ -0,0 +1,265 @@ +import { TestBed } from '@suites/unit'; +import { Mocked } from '@suites/doubles.vitest'; +import { ModuleRef } from '@nestjs/core'; +import { AclAuthorizationService } from './acl-authorization.service'; +import { ACL_CONTEXT_KEY, ACL_MODULE_OPTIONS } from '../constants'; +import type { AclModuleOptions, AclRulesLoader } from '../types'; +import { ABILITY_FACTORY } from '../factories'; +import { assert, describe, expect } from 'vitest'; +import { ForbiddenException } from '@nestjs/common'; + +// Mock Logger to avoid actual logging in tests +const mockLoggerDebug = vi.fn(); +const mockLoggerWarn = vi.fn(); +const mockLoggerError = vi.fn(); + +vi.mock('@nestjs/common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Logger: class Logger { + debug = mockLoggerDebug; + warn = mockLoggerWarn; + error = mockLoggerError; + }, + }; +}); + +describe('AclAuthorizationService', () => { + let aclAuthorizationService: AclAuthorizationService; + let moduleRef: Mocked; + let mockOptions: AclModuleOptions; + + const controllerName = 'UsersController'; + const controllerMethod = 'getAll'; + const subject = class User {}; + + const loadRulesMock = vi.fn(); + const getContextMock = vi.fn(); + const getHelpersMock = vi.fn(); + const canMock = vi.fn(); + // Mock contextStore + const mockContextStore = { + set: vi.fn(), + get: vi.fn(), + }; + const mockAbilityFactory = vi.fn().mockReturnValue({ + can: canMock, + }); + // Mock rulesLoader + const mockRulesLoader: AclRulesLoader = { + loadRules: loadRulesMock, + getContext: getContextMock, + getHelpers: getHelpersMock, + }; + + // Mock ExecutionContext + + beforeEach(async () => { + // Default module options + mockOptions = { + rulesLoader: 'RulesLoader' as any, + contextStore: 'ContextStore' as any, + onNoRules: 'deny', + defaultRules: [], + }; + + const { unit, unitRef } = await TestBed.solitary(AclAuthorizationService) + .mock(ACL_MODULE_OPTIONS) + .impl(() => mockOptions) + .mock(ABILITY_FACTORY) + .impl(() => mockAbilityFactory) + .compile(); + + aclAuthorizationService = unit; + // @ts-expect-error incorrect type + moduleRef = unitRef.get(ModuleRef); + + // Setup default moduleRef behavior + moduleRef.get.mockImplementation((token: any) => { + if (token === mockOptions.rulesLoader) { + return mockRulesLoader as any; + } + if (token === mockOptions.contextStore) { + return mockContextStore as any; + } + return undefined; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Use controller options', () => { + it('Should be forbidden when no rules is not array and onNoRules=deny but global options is allow', async () => { + mockOptions.onNoRules = 'allow'; + try { + await aclAuthorizationService.authorize( + controllerName, + controllerMethod, + { + enabled: true, + subject, + methods: { + [controllerMethod]: { + onNoRules: 'deny', + } + }, + } + ); + assert.fail('Should throw ForbiddenException'); + } catch (err) { + expect(err).instanceof(ForbiddenException); + expect(mockLoggerError).toBeCalledWith( + `No ACL rules defined for ${controllerName}.${controllerMethod}, denying access (onNoRules: 'deny')` + ); + expect(loadRulesMock).toBeCalledWith(subject, controllerMethod); + } + }); + }) + + describe('Not use controller options', () => { + it('Should be forbidden when no rules is not array and onNoRules=deny', async () => { + try { + await aclAuthorizationService.authorize( + controllerName, + controllerMethod, + { + enabled: true, + subject, + methods: {}, + } + ); + assert.fail('Should throw ForbiddenException'); + } catch (err) { + expect(err).instanceof(ForbiddenException); + expect(mockLoggerError).toBeCalledWith( + `No ACL rules defined for ${controllerName}.${controllerMethod}, denying access (onNoRules: 'deny')` + ); + expect(loadRulesMock).toBeCalledWith(subject, controllerMethod); + } + }); + + it('Should be forbidden when no rules is empty array and onNoRules=deny', async () => { + loadRulesMock.mockResolvedValue([]); + try { + await aclAuthorizationService.authorize( + controllerName, + controllerMethod, + { + enabled: true, + subject, + methods: {}, + } + ); + assert.fail('Should throw ForbiddenException'); + } catch (err) { + expect(err).instanceof(ForbiddenException); + expect(mockLoggerError).toBeCalledWith( + `No ACL rules defined for ${controllerName}.${controllerMethod}, denying access (onNoRules: 'deny')` + ); + expect(loadRulesMock).toBeCalledWith(subject, controllerMethod); + } + }); + + it('Should be allowed when no rules is not array and onNoRules=allow', async () => { + mockOptions.onNoRules = 'allow'; + canMock.mockReturnValue(true); + loadRulesMock.mockResolvedValue([]); + getContextMock.mockResolvedValue({}); + getHelpersMock.mockResolvedValue({}); + const result = await aclAuthorizationService.authorize( + controllerName, + controllerMethod, + { + enabled: true, + subject, + methods: {}, + } + ); + + expect(result).toBe(true); + expect(loadRulesMock).toBeCalledWith(subject, controllerMethod); + expect(mockLoggerWarn).toBeCalledWith( + `No ACL rules defined for ${controllerName}.${controllerMethod}, allowing access with permissive rule (onNoRules: 'allow')` + ); + expect(mockAbilityFactory).toBeCalledWith( + subject.name, + controllerMethod, + [{ action: controllerMethod, subject: subject.name }], + {}, + {} + ); + expect(mockContextStore.set).toBeCalledWith(ACL_CONTEXT_KEY, mockAbilityFactory.mock.results[0].value); + }); + + it('Should be forbidden when rules not allow access', async () => { + + loadRulesMock.mockResolvedValue([{ action: 'getAll', subject: 'User', inverted: true }]); + getContextMock.mockResolvedValue({}); + getHelpersMock.mockResolvedValue({}); + canMock.mockReturnValue(false); + try { + await aclAuthorizationService.authorize( + controllerName, + controllerMethod, + { + enabled: true, + subject, + methods: {}, + } + ) + assert.fail('Should throw ForbiddenException'); + } catch (err) { + expect(err).instanceof(ForbiddenException); + expect(loadRulesMock).toBeCalledWith(subject, controllerMethod); + expect(mockAbilityFactory).toBeCalledWith( + subject.name, + controllerMethod, + await loadRulesMock.mock.results[0].value, + {}, + {} + ); + } + }) + + it('Should be forbidden when rules not allow access', async () => { + + loadRulesMock.mockResolvedValue([]); + getContextMock.mockResolvedValue({}); + getHelpersMock.mockResolvedValue({}); + canMock.mockReturnValue(false); + const defaultRules = [{ action: 'getAll', subject: 'User', inverted: true }]; + try { + await aclAuthorizationService.authorize( + controllerName, + controllerMethod, + { + enabled: true, + subject, + methods: { + [controllerMethod]: { + defaultRules, + } + }, + } + ) + assert.fail('Should throw ForbiddenException'); + } catch (err) { + expect(err).instanceof(ForbiddenException); + expect(loadRulesMock).toBeCalledWith(subject, controllerMethod); + expect(mockAbilityFactory).toBeCalledWith( + subject.name, + controllerMethod, + defaultRules, + {}, + {} + ); + expect(mockLoggerDebug).toBeCalledWith( + `No rules for ${controllerName}.${controllerMethod}, applying defaultRules` + ); + } + }) + }); +}); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/acl-authorization.service.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/acl-authorization.service.ts new file mode 100644 index 00000000..384b58da --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/acl-authorization.service.ts @@ -0,0 +1,140 @@ +import { Injectable, ForbiddenException, Logger, Inject } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { ACL_MODULE_OPTIONS, ACL_CONTEXT_KEY } from '../constants'; +import { + AclControllerMetadata, + AclControllerMethodsOptions, + AclModuleOptions, + AclRule, + AclRulesLoader, + type AclSubject, +} from '../types'; +import { getActionOptions } from '../utils'; +import { ABILITY_FACTORY, AbilityFactory } from '../factories'; + + +@Injectable() +export class AclAuthorizationService { + @Inject(ModuleRef) private readonly moduleRef!: ModuleRef; + @Inject(ACL_MODULE_OPTIONS) private readonly options!: AclModuleOptions; + @Inject(ABILITY_FACTORY) private readonly abilityFactory!: AbilityFactory; + + private readonly logger = new Logger(AclAuthorizationService.name); + + async authorize( + controllerName: string, + action: string, + metaData: AclControllerMetadata + ): Promise { + const { subject: subjectForRules } = metaData; + + const actionOptions = getActionOptions( + this.options, + metaData.methods[action] + ); + const subject = this.getSubjectFromInput(subjectForRules); + + const rulesLoader = this.moduleRef.get( + this.options.rulesLoader, + { strict: false } + ); + + const [rules, context, helpers] = await Promise.all([ + rulesLoader.loadRules(subjectForRules, action), + rulesLoader.getContext(), + rulesLoader.getHelpers(), + ]); + + const resultRules = + Array.isArray(rules) && rules.length > 0 + ? rules + : this.getDefaultRulesForAction( + controllerName, + action, + subject, + actionOptions + ); + + const ability = this.abilityFactory(subject, action, resultRules, context, helpers); + if (!ability.can(action, subject)) { + this.logger.debug( + `Access denied for ${controllerName}.${action} (action: ${action}, subject: ${subject})` + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow "${action}"`, + path: ['action'], + }, + ], + { + description: `Access denied for ${action} on ${subject}`, + } + ); + } + + const contextStore = this.moduleRef.get(this.options.contextStore, { + strict: false, + }); + + contextStore.set(ACL_CONTEXT_KEY, ability); + + return true; + } + + private getDefaultRulesForAction( + controllerName: string, + action: string, + subject: AclSubject, + actionOptions: Exclude + ): AclRule[] { + if (actionOptions.defaultRules && actionOptions.defaultRules.length > 0) { + this.logger.debug( + `No rules for ${controllerName}.${action}, applying defaultRules` + ); + return actionOptions.defaultRules; + } + + const policy = actionOptions.onNoRules || 'deny'; + + if (policy === 'deny') { + this.logger.error( + `No ACL rules defined for ${controllerName}.${action}, denying access (onNoRules: '${policy}')` + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow access`, + path: [], + }, + ], + { + description: `No ACL rules defined for ${controllerName}.${action}`, + } + ); + } + + this.logger.warn( + `No ACL rules defined for ${controllerName}.${action}, allowing access with permissive rule (onNoRules: '${policy}')` + ); + return [ + { + action, + subject, + }, + ]; + } + + private getSubjectFromInput(subject: AclSubject): string { + if (typeof subject === 'string') { + return subject; + } + if (typeof subject === 'function' && subject.name) { + return subject.name; + } + + throw new Error('Entity shouldbe class or string'); + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/index.ts new file mode 100644 index 00000000..faa01300 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/index.ts @@ -0,0 +1,2 @@ +export * from './acl-authorization.service'; +export * from './rule-materializer.service' diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/rule-materializer.service.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/rule-materializer.service.spec.ts new file mode 100644 index 00000000..815eed35 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/rule-materializer.service.spec.ts @@ -0,0 +1,853 @@ +import { expect } from 'vitest'; +import { RuleMaterializer } from './rule-materializer.service'; +import { TestBed } from '@suites/unit'; +import { ACL_MODULE_OPTIONS } from '../constants'; +import { AbilityTuple, MongoQuery, RawRuleFrom } from '@casl/ability'; + +vi.mock('@nestjs/common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Logger: class Logger { + debug = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + }, + }; +}); + +describe('RuleMaterializer', () => { + describe('Non-strict mode', () => { + let materializer: RuleMaterializer; + + beforeEach(async () => { + + const { unit } = await TestBed.solitary(RuleMaterializer) + .mock(ACL_MODULE_OPTIONS) + .impl(() => ({ + strictInterpolation: false, + })) + .compile(); + materializer = unit; + }); + + describe('Simple variable interpolation', () => { + it('should interpolate simple string variable with subject as class', () => { + class User {} + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: User, + conditions: { name: '${userName}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { userName: 'John' }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: User.name, + conditions: { name: 'John' }, + }, + ]); + }); + + it('should interpolate simple string variable', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { name: '${userName}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { userName: 'John' }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { name: 'John' }, + }, + ]); + }); + + it('should interpolate number variable', () => { + const rules: RawRuleFrom[] = [ + { + action: 'patchOne', + subject: 'User', + conditions: { id: '${userId}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { userId: 123 }, + {}, + ); + + expect(result).toEqual([ + { + action: 'patchOne', + subject: 'User', + conditions: { id: 123 }, + }, + ]); + }); + + it('should interpolate boolean variable', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { published: '${isPublished}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { isPublished: true }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { published: true }, + }, + ]); + }); + + it('should interpolate array variable', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { groupIds: { $in: '${groupIds}' } }, + }, + ]; + + const result = materializer.materialize( + rules, + { groupIds: [1, 5, 8] }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { groupIds: { $in: [1, 5, 8] } }, + }, + ]); + }); + + it('should interpolate null value', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { deletedAt: '${deletedAt}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { deletedAt: null }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { deletedAt: null }, + }, + ]); + }); + + it('should interpolate Date value', () => { + const date = new Date('2025-01-15T10:30:00.000Z'); + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { createdAfter: '${createdDate}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { createdDate: date }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { createdAfter: date.toISOString() }, + }, + ]); + }); + }); + + describe('Nested property access', () => { + it('should interpolate nested object properties', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { email: '${user.email}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { user: { email: 'john@example.com', id: 123 } }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { email: 'john@example.com' }, + }, + ]); + }); + + it('should interpolate deeply nested properties', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { groupName: '${user.groups[0].name}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { + user: { + groups: [{ name: 'Admin', id: 1 }], + }, + }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { groupName: 'Admin' }, + }, + ]); + }); + }); + + describe('Helper functions', () => { + it('should call helper function and use result', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { groupIds: { $in: '${getGroupIds(user.groups)}' } }, + }, + ]; + + const helpers = { + getGroupIds: (groups: Array<{ id: number }>) => + groups.map((g) => g.id), + }; + + const result = materializer.materialize( + rules, + { user: { groups: [{ id: 1 }, { id: 5 }, { id: 8 }] } }, + helpers, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { groupIds: { $in: [1, 5, 8] } }, + }, + ]); + }); + + it('should call helper with multiple arguments', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { active: '${checkStatus(user.status, "active")}' }, + }, + ]; + + const helpers = { + checkStatus: (status: string, expected: string) => status === expected, + }; + + const result = materializer.materialize( + rules, + { user: { status: 'active' } }, + helpers, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { active: true }, + }, + ]); + }); + }); + + describe('@input variable (external data)', () => { + it('should interpolate @input variable', () => { + const rules: RawRuleFrom[] = [ + { + action: 'patchOne', + subject: 'User', + conditions: { id: '${@input.userId}' }, + }, + ]; + + const result = materializer.materialize( + rules, + {}, + {}, + { userId: 456 }, + ); + + expect(result).toEqual([ + { + action: 'patchOne', + subject: 'User', + conditions: { id: 456 }, + }, + ]); + }); + + it('should use @input with helper functions', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { groupIds: { $in: '${getValProps(@input.groups, "id")}' } }, + }, + ]; + + const helpers = { + getValProps: (arr: any[], prop: string) => + arr.map((item) => item[prop]), + }; + + const result = materializer.materialize( + rules, + {}, + helpers, + { groups: [{ id: 1, name: 'A' }, { id: 5, name: 'B' }] }, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { groupIds: { $in: [1, 5] } }, + }, + ]); + }); + + it('should combine context and @input variables', () => { + const rules: RawRuleFrom[] = [ + { + action: 'patchOne', + subject: 'Post', + conditions: { + authorId: '${currentUserId}', + postId: '${@input.id}', + }, + }, + ]; + + const result = materializer.materialize( + rules, + { currentUserId: 123 }, + {}, + { id: 789 }, + ); + + expect(result).toEqual([ + { + action: 'patchOne', + subject: 'Post', + conditions: { + authorId: 123, + postId: 789, + }, + }, + ]); + }); + }); + + describe('Special characters and escaping', () => { + it('should handle strings with quotes', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { name: '${userName}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { userName: 'O\'Brien' }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { name: 'O\'Brien' }, + }, + ]); + }); + + it('should handle strings with double quotes', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { description: '${desc}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { desc: 'Said "hello" loudly' }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { description: 'Said "hello" loudly' }, + }, + ]); + }); + + it('should handle strings with backslashes', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'File', + conditions: { path: '${filePath}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { filePath: 'C:\\temp\\file.txt' }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'File', + conditions: { path: 'C:\\temp\\file.txt' }, + }, + ]); + }); + + it('should handle strings with newlines and tabs', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Text', + conditions: { content: '${text}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { text: 'Line 1\nLine 2\tTabbed' }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Text', + conditions: { content: 'Line 1\nLine 2\tTabbed' }, + }, + ]); + }); + }); + + describe('Multiple rules and conditions', () => { + it('should materialize multiple rules', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { id: '${userId}' }, + }, + { + action: 'patchOne', + subject: 'Post', + conditions: { authorId: '${userId}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { userId: 123 }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { id: 123 }, + }, + { + action: 'patchOne', + subject: 'Post', + conditions: { authorId: 123 }, + }, + ]); + }); + + it('should materialize rules with multiple conditions', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { + authorId: '${userId}', + published: '${isPublished}', + groupIds: { $in: '${groupIds}' }, + }, + }, + ]; + + const result = materializer.materialize( + rules, + { userId: 123, isPublished: true, groupIds: [1, 5, 8] }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { + authorId: 123, + published: true, + groupIds: { $in: [1, 5, 8] }, + }, + }, + ]); + }); + + it('should materialize rules with fields', () => { + const rules: RawRuleFrom[] = [ + { + action: 'patchOne', + subject: 'User', + conditions: { id: '${userId}' }, + fields: ['name', 'email'], + }, + ]; + + const result = materializer.materialize( + rules, + { userId: 123 }, + {}, + ); + + expect(result).toEqual([ + { + action: 'patchOne', + subject: 'User', + conditions: { id: 123 }, + fields: ['name', 'email'], + }, + ]); + }); + + it('should preserve inverted and reason fields', () => { + const rules: RawRuleFrom[] = [ + { + action: 'deleteOne', + subject: 'Post', + conditions: { id: '${postId}' }, + inverted: true, + reason: 'Cannot delete this post', + }, + ]; + + const result = materializer.materialize( + rules, + { postId: 789 }, + {}, + ); + + expect(result).toEqual([ + { + action: 'deleteOne', + subject: 'Post', + conditions: { id: 789 }, + inverted: true, + reason: 'Cannot delete this post', + }, + ]); + }); + }); + + describe('MongoDB operators', () => { + it('should work with $in operator', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { status: { $in: '${statuses}' } }, + }, + ]; + + const result = materializer.materialize( + rules, + { statuses: ['published', 'draft'] }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { status: { $in: ['published', 'draft'] } }, + }, + ]); + }); + + it('should work with $gt operator', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { views: { $gt: '${minViews}' } }, + }, + ]; + + const result = materializer.materialize( + rules, + { minViews: 100 }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { views: { $gt: 100 } }, + }, + ]); + }); + + it('should work with complex nested conditions', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'Post', + conditions: { + $or: [ + { authorId: '${userId}' }, + { collaborators: { $in: '${userId}' } }, + ], + }, + }, + ]; + + const result = materializer.materialize( + rules, + { userId: 123 }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'Post', + conditions: { + $or: [ + { authorId: 123 }, + { collaborators: { $in: 123 } }, + ], + }, + }, + ]); + }); + }); + + describe('Edge cases', () => { + it('should handle empty rules array', () => { + const rules: RawRuleFrom[] = []; + + const result = materializer.materialize(rules, {}, {}); + + expect(result).toEqual([]); + }); + + it('should handle rules without conditions', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + }, + ]; + + const result = materializer.materialize(rules, {}, {}); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + }, + ]); + }); + + it('should handle rules without template variables', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { active: true }, + }, + ]; + + const result = materializer.materialize(rules, {}, {}); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { active: true }, + }, + ]); + }); + + it('should handle empty @input', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { userId: '${currentUserId}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { currentUserId: 123 }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { userId: 123 }, + }, + ]); + }); + }); + }); + + describe('Strict mode', () => { + let materializer: RuleMaterializer; + + beforeEach(async () => { + const { unit } = await TestBed.solitary(RuleMaterializer) + .mock(ACL_MODULE_OPTIONS) + .impl(() => ({ + strictInterpolation: true, + })) + .compile(); + materializer = unit; + }); + + it('should throw error for missing variable in strict mode', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { id: '${nonExistent}' }, + }, + ]; + + expect(() => { + materializer.materialize(rules, {}, {}); + }).toThrow(); + }); + + it('should throw error for undefined @input property', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { id: '${@input.nonExistent}' }, + }, + ]; + + expect(() => { + materializer.materialize(rules, {}, {}, {}); + }).toThrow(); + }); + + it('should successfully materialize when all variables exist', () => { + const rules: RawRuleFrom[] = [ + { + action: 'getAll', + subject: 'User', + conditions: { id: '${userId}' }, + }, + ]; + + const result = materializer.materialize( + rules, + { userId: 123 }, + {}, + ); + + expect(result).toEqual([ + { + action: 'getAll', + subject: 'User', + conditions: { id: 123 }, + }, + ]); + }); + }); +}); + + + + diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/rule-materializer.service.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/rule-materializer.service.ts new file mode 100644 index 00000000..22532e82 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/services/rule-materializer.service.ts @@ -0,0 +1,314 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { AbilityTuple, MongoQuery, RawRuleFrom } from '@casl/ability'; +import type { AclInputData, AclModuleOptions } from '../types'; +import { ACL_INPUT_VAR, ACL_INPUT_TEMPLATE } from '../types'; +import { ACL_MODULE_OPTIONS } from '../constants'; + + +/** + * Service for materializing ACL rules by interpolating template variables + * + * Converts rules with templates like { userId: '${@input.userId}' } + * into materialized rules with actual values like { userId: 123 } + * + * Uses Tagged Template Literals via Function for safe and efficient evaluation + */ +@Injectable() +export class RuleMaterializer { + readonly logger = new Logger(RuleMaterializer.name); + + // Temporary placeholder to distinguish quotes inside strings from quotes around placeholders + // Example: {"name":"John"} has quotes inside, but {"name":"${userName}"} has placeholder quotes + private readonly QUOTE_PLACEHOLDER = '\u0000QUOTE\u0000'; + @Inject(ACL_MODULE_OPTIONS) private readonly options!: AclModuleOptions; + + private get strictMode(): boolean { + return !this.options ? true : !!this.options.strictInterpolation + } + + /** + * Materializes ACL rules by replacing template variables with actual values + * + * @param rules - Array of rules with template strings + * @param context - Variables from getContext() + * @param helpers - Helper functions from getHelpers() + * @param input - External input data (available as @input in templates) + * @returns Materialized rules with interpolated values + * + * TODO: Support old/current values in addition to new input + * For patchOne operations, we need access to both: + * - @input.* - new values from request + * - @input.__current.* - old values from database + * This enables rules like "allow removing only self from array" + * + * @example + * ```typescript + * const rules = [{ conditions: { userId: '${@input.id}' } }]; + * const context = { currentUserId: 123 }; + * const helpers = {}; + * const input = { id: 456 }; + * + * const materialized = materializer.materialize(rules, context, helpers, input); + * // Result: [{ conditions: { userId: 456 } }] + * ``` + */ + + + materialize< + A extends AbilityTuple = AbilityTuple, + C extends MongoQuery = MongoQuery + >( + rules: RawRuleFrom[], + context: Record, + helpers: Record, + input?: AclInputData, + ): RawRuleFrom[] { + + // In strict mode, wrap input in Proxy to catch undefined property access + const inputData = input || {}; + const wrappedInput = this.strictMode + ? this.createStrictProxy(inputData, ACL_INPUT_VAR) + : inputData; + + // Build scope with context, helpers, and input (without @) + const scope = { + ...context, + ...helpers, + [ACL_INPUT_VAR]: wrappedInput, // 'input' not '@input' + }; + + try { + + // Convert rules to JSON string + let jsonStr = JSON.stringify(rules, (k, v) => k === 'subject' && typeof v === 'function' ? v.name : v); + + // Replace @input with input (@ is not valid in JS variable names) + jsonStr = jsonStr.replaceAll( + ACL_INPUT_TEMPLATE, + ACL_INPUT_VAR, + ); + + // Replace \" with ' inside ${...} template expressions + // JSON.stringify escapes quotes as \" but inside template expressions we need ' + // Example: ${checkStatus(user.status, \"active\")} → ${checkStatus(user.status, 'active')} + jsonStr = jsonStr.replace(/\$\{([^}]+)\}/g, (match, expr) => { + const fixedExpr = expr.replace(/\\"/g, "'"); + return `\${${fixedExpr}}`; + }); + + // Replace remaining \" with placeholder (outside of ${...}) + // This helps distinguish quotes around placeholders from quotes inside string values + jsonStr = jsonStr.replace(/\\"/g, this.QUOTE_PLACEHOLDER); + + // Escape backticks for template literal + jsonStr = this.escapeForTemplateLiteral(jsonStr); + this.logger.debug(`JSON template prepared: ${jsonStr.substring(0, 200)}...`); + + // Evaluate using tagged template + const result = this.evaluateTaggedTemplate(jsonStr, scope); + + // Parse back to object + const materialized = JSON.parse(result) as RawRuleFrom[]; + + this.logger.debug( + `Materialized ${rules.length} rule(s) successfully`, + ); + + return materialized; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.handleError(errorMessage, scope); + throw error; // Re-throw after logging + } + } + + /** + * Escapes special characters for use in template literal + */ + private escapeForTemplateLiteral(str: string): string { + return ( + str + // Escape backticks only + // Don't escape $ because we WANT ${...} to work as template expressions + .replace(/`/g, '\\`') + ); + } + + /** + * Evaluates JSON template using Tagged Template Literals + * + * Uses a tagged function that correctly formats different types: + * - strings → wrapped in quotes + * - arrays → JSON.stringify + * - numbers/booleans → as-is + */ + private evaluateTaggedTemplate( + jsonTemplate: string, + scope: Record, + ): string { + const scopeKeys = Object.keys(scope); + const scopeValues = Object.values(scope); + + // Tagged template function that handles type formatting + const taggedFunc = this.createTaggedFunction(); + + // Create function: taggedFunc`${userId}${groupIds}...` + // Example: new Function('tag', 'userId', 'groupIds', 'return tag`...${userId}...${groupIds}...`') + const functionBody = `return tag\`${jsonTemplate}\`;`; + + // Debug logging + if (functionBody.length > 500) { + this.logger.debug(`Function body (first 500 chars): ${functionBody.substring(0, 500)}`); + } else { + this.logger.debug(`Function body: ${functionBody}`); + } + + const evalFn = new Function( + 'tag', + ...scopeKeys, + functionBody, + ); + + // Execute with tagged function and scope values + const result = evalFn(taggedFunc, ...scopeValues); + + return result; + } + + /** + * Creates tagged template function that formats placeholders by type + * + * IMPORTANT: Values are already inside JSON string quotes from stringify + * We need to REMOVE the quotes and insert raw JSON values + */ + private createTaggedFunction(): ( + literals: TemplateStringsArray, + ...placeholders: any[] + ) => string { + return (literals: TemplateStringsArray, ...placeholders: any[]) => { + let result = ''; + const placeholdersLength = placeholders.length; + + for (let i = 0; i < placeholdersLength; i++) { + let literal = literals[i]; + + // Restore placeholder back to \" + literal = literal.replace( + new RegExp(this.QUOTE_PLACEHOLDER, 'g'), + '\\"', + ); + + // Check if literal ends with opening quote: " + // If so, we need to remove it and the closing quote from next literal + const endsWithQuote = literal.endsWith('"'); + if (endsWithQuote) { + literal = literal.slice(0, -1); // Remove trailing " + } + + result += literal; + + const value = placeholders[i]; + + // Format value based on type + if (value === null) { + result += 'null'; + } else if (value === undefined) { + result += 'null'; // JSON doesn't have undefined + } else if (typeof value === 'string') { + // Escape quotes and special characters for JSON string + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); + result += `"${escaped}"`; + } else if (typeof value === 'number' || typeof value === 'boolean') { + result += String(value); + } else if (Array.isArray(value)) { + result += JSON.stringify(value); + } else if (value instanceof Date) { + result += `"${value.toISOString()}"`; + } else if (typeof value === 'object') { + result += JSON.stringify(value); + } else { + // Fallback + result += JSON.stringify(value); + } + + // Handle closing quote if we removed opening quote + if (endsWithQuote && i + 1 < literals.length) { + const nextLiteral = literals[i + 1]; + if (nextLiteral.startsWith('"')) { + // Skip the closing quote + literals = Object.assign([], literals, { + [i + 1]: nextLiteral.slice(1), + }) as any; + } + } + } + + // Add final literal (restore placeholder here too) + let finalLiteral = literals[literals.length - 1]; + finalLiteral = finalLiteral.replace( + new RegExp(this.QUOTE_PLACEHOLDER, 'g'), + '\\"', + ); + result += finalLiteral; + + return result; + }; + } + + /** + * Creates strict Proxy that throws error on undefined property access + * Used in strict mode to catch template errors early + */ + private createStrictProxy>( + obj: T, + name: string, + ): T { + return new Proxy(obj, { + get: (target, prop: string | symbol) => { + // Allow symbol properties (like Symbol.toStringTag, Symbol.iterator, etc.) + if (typeof prop === 'symbol') { + return target[prop as any]; + } + + // Check if property exists + if (!(prop in target)) { + throw new ReferenceError( + `Property '${name}.${String(prop)}' is not defined in strict mode`, + ); + } + + const value = target[prop]; + + // If value is an object, wrap it in Proxy too (for nested access) + if (value && typeof value === 'object' && !Array.isArray(value)) { + return this.createStrictProxy(value, `${name}.${String(prop)}`); + } + + return value; + }, + }); + } + + /** + * Handles interpolation errors based on strict mode + */ + private handleError( + errorMessage: string, + scope: Record, + ): void { + const scopeInfo = `Available variables: ${Object.keys(scope).join(', ')}`; + const fullMessage = `Failed to materialize rules: ${errorMessage}. ${scopeInfo}`; + + if (this.strictMode) { + this.logger.error(fullMessage); + } else { + this.logger.warn(fullMessage); + } + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-context.types.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-context.types.ts new file mode 100644 index 00000000..66f9cce1 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-context.types.ts @@ -0,0 +1,19 @@ + +/** + * Interface for context storage (e.g., ClsService from nestjs-cls) + * Allows passing rules and data through the request pipeline + */ +export interface AclContextStore { + /** + * Set value in context + */ + set(key: any, value: T): void; + + /** + * Get value from context + */ + get(key: any): T | undefined; +} + + + diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-options.types.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-options.types.ts new file mode 100644 index 00000000..dc9d1609 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-options.types.ts @@ -0,0 +1,104 @@ +import type { Type } from '@nestjs/common'; +import { AclRule, AclRulesLoader } from './acl-rules.types'; +import { AclContextStore } from './acl-context.types'; + +/** + * Policy for handling resources with no ACL rules defined + */ +export type OnNoRulesPolicy = 'deny' | 'allow'; + +/** + * Options for configuring ACL module + */ +export interface AclModuleOptions { + /** + * Service class that loads ACL rules from external source + * Must implement AclRulesLoader interface + * Will be retrieved via moduleRef to support services from other modules + */ + rulesLoader: Type; + + /** + * Context store for passing ACL data through request pipeline + * Required to access ExtendableAbility in pipes/guards/services via CLS + * Can be ClsService from nestjs-cls or any service with set/get methods + * + * @example + * ```typescript + * import { ClsService } from 'nestjs-cls'; + * + * AclPermissionsModule.forRoot({ + * rulesLoader: MyRulesLoader, + * contextStore: ClsService + * }) + * ``` + */ + contextStore: Type; + + /** + * Strict mode for template interpolation in rules + * + * - `true`: Throws error if variable/function not found in context + * - `false` (default): Logs warning and omits field with missing variable + * + * @default false + * + * @example + * ```typescript + * // Development mode - fail fast on configuration errors + * AclPermissionsModule.forRoot({ + * rulesLoader: MyRulesLoader, + * strictInterpolation: true + * }) + * + * // Production mode - graceful degradation + * AclPermissionsModule.forRoot({ + * rulesLoader: MyRulesLoader, + * strictInterpolation: false + * }) + * ``` + */ + strictInterpolation?: boolean; + + /** + * Policy for handling resources with no ACL rules defined + * + * - 'deny': Throw 403 Forbidden - DEFAULT (production mode) + * - 'allow': Allow access + warning in logs (development mode) + * + * @default 'deny' + * + * @example + * ```typescript + * // Production - deny by default (strict) + * AclPermissionsModule.forRoot({ + * rulesLoader: MyRulesLoader, + * onNoRules: 'deny' + * }) + * + * // Development - allow access with warning + * AclPermissionsModule.forRoot({ + * rulesLoader: MyRulesLoader, + * onNoRules: 'allow' + * }) + * ``` + */ + onNoRules?: OnNoRulesPolicy; + + /** + * Дефолтные правила fallback (опционально) + * Используются когда rulesLoader возвращает пустой массив + * + * @example + * ```typescript + * AclPermissionsModule.forRoot({ + * rulesLoader: MyRulesLoader, + * defaultRules: [ + * { action: 'getAll', subject: 'all', inverted: false } + * ] + * }) + * ``` + */ + defaultRules?: AclRule[]; + +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-rules.types.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-rules.types.ts new file mode 100644 index 00000000..62e174e6 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl-rules.types.ts @@ -0,0 +1,224 @@ +import type { MongoQuery } from '@casl/ability'; +import type { MethodName } from '@klerick/json-api-nestjs'; +import type { AnyEntity, EntityClass } from '@klerick/json-api-nestjs-shared'; + +/** + * Reserved variable name for external input data in rule templates + * This data comes from outside (request body, query params, database entity, etc.) + * and is NOT part of the context returned by getContext() + * + * In templates, use @input (with @) for readability: '${@input.userId}' + * Internally, the @ symbol is removed before interpolation since @ is not valid in JS variable names + * + * @example + * ```typescript + * // In rule template (user-facing): + * { conditions: { ownerId: '${@input.userId}' } } + * + * // Internally reserved variable name (without @): + * const scope = { input: externalData }; + * ``` + */ +export const ACL_INPUT_VAR = 'input' as const; + +/** + * Template placeholder with @ symbol for user-facing templates + * This is replaced with ACL_INPUT_VAR before interpolation + */ +export const ACL_INPUT_TEMPLATE = '@input' as const; + +/** + * Type for external input data + * + * TODO: Extend to support both new input AND old values from database + * Use case: In patchOne, we need to compare old value vs new value in rules + * Example: Allow removing only self from coAuthorIds array + * - Current: { coAuthorIds: [1, 2, 3] } from DB + * - New: { coAuthorIds: [1, 3] } from @input + * - Helper: isOnlyRemovedUser(@input.__current.coAuthorIds, @input.coAuthorIds, userId) + * + * Proposed structure: + * - @input.* - new values from request + * - @input.__current.* - old values from database (for patchOne only) + */ +export type AclInputData = Record; + +/** + * Utility type to exclude reserved @input variable from context/helpers + */ +type WithoutReservedVars> = { + [K in keyof T as K extends typeof ACL_INPUT_VAR ? never : K]: T[K]; +}; + +/** + * Type for CASL action + * Can be any method from JsonBaseController or custom string + */ +export type AclAction = E; + +/** + * Type for CASL subject - entity class or instance + * Can be: + * - Entity class (e.g., User, Post) + * - Entity instance (e.g., new User()) + * - String with entity name (e.g., 'User', 'Post') + */ +export type AclSubject = + | EntityClass + | E + | string; + +/** + * ACL rule definition + */ +export interface AclRule< + E extends AnyEntity = AnyEntity, + Action extends string = string +> { + /** + * Action to check (e.g., 'getAll', 'postOne', 'patchOne', etc.) + */ + action: Action; + + /** + * Subject to check against (entity class or name) + */ + subject: AclSubject; + + /** + * Optional conditions (MongoDB query format) + */ + conditions?: MongoQuery; + + /** + * Optional fields restriction + */ + fields?: Array; + + /** + * Whether this is an inverted rule (cannot) + */ + inverted?: boolean; + + /** + * Optional reason for the rule + */ + reason?: string; +} + +/** + * Interface for loading ACL rules from external source + * Implementation is provided by the user + */ +export interface AclRulesLoader { + /** + * Loads ACL rules for the current request + * + * @param subject - Entity class or name of subject for which to load rules + * @param action - Action being performed (method name from JsonBaseController) + * @returns Array of CASL rules in JSON format (may contain template strings like '${userId}') + * + * @example + * ```typescript + * @Injectable() + * class MyRulesLoader implements AclRulesLoader { + * async loadRules( + * entity: EntityClass, + * action: AclAction + * ): Promise[]> { + * const user = this.request.user; + * const rules = await this.db.query( + * 'SELECT * FROM permissions WHERE userId = ? AND entity = ? AND action = ?', + * [user.id, entity.name, action] + * ); + * return rules.map(r => ({ + * action: r.action, + * subject: entity, + * conditions: r.conditions, // May contain: { userId: '${userId}' } + * fields: r.fields + * })); + * } + * } + * ``` + */ + loadRules( + subject: AclSubject, + action: AclAction + ): Promise[]>; + + /** + * Provides context variables for template interpolation in rules + * + * IMPORTANT: Cannot use reserved variable name 'input' - it's reserved for external input data + * In templates, write @input which gets converted to input internally + * + * @returns Promise with object containing variables (without 'input' key) + * + * @example + * ```typescript + * @Injectable() + * class MyRulesLoader implements AclRulesLoader { + * constructor( + * @Inject(REQUEST) private request: Request, + * private usersService: UsersService + * ) {} + * + * async getContext() { + * const user = this.request.user; + * const userGroups = await this.usersService.getUserGroups(user.id); + * + * return { + * userId: user.id, + * userEmail: user.email, + * userData: { + * groups: userGroups, + * roles: user.roles, + * }, + * // 'input': {} // ← TypeScript error: reserved variable + * }; + * } + * } + * ``` + */ + getContext(): Promise>>; + + /** + * Provides custom helper functions for template interpolation in rules + * + * These functions can be used in rule templates like: '${getValProps(@input.userGroups, "id")}' + * + * IMPORTANT: Cannot use reserved function name 'input' - it's reserved for external input data + * In templates, write @input which gets converted to input internally + * + * @returns Promise with object containing helper functions (without 'input' key) + * + * @example + * ```typescript + * @Injectable() + * class MyRulesLoader implements AclRulesLoader { + * async getHelpers() { + * return { + * // Extract property values from array of objects + * getValProps: (arr: any[], prop: string) => arr.map(item => item[prop]), + * + * // Check if array contains value + * includes: (arr: any[], value: any) => arr.includes(value), + * + * // Get current timestamp + * now: () => Date.now(), + * + * // Custom business logic + * isOwner: (resource: any, userId: number) => resource.ownerId === userId, + * + * // 'input': () => {} // ← TypeScript error: reserved variable + * }; + * } + * } + * + * // Usage in rule template: + * // { groupIds: { $in: '${getValProps(@input.userGroups, "id")}' } } + * // { ownerId: '${@input.userId}' } + * ``` + */ + getHelpers(): Promise any>>>; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/decorator-options.types.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/decorator-options.types.ts new file mode 100644 index 00000000..bc5dd4c9 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/decorator-options.types.ts @@ -0,0 +1,77 @@ +import type { JsonBaseController } from '@klerick/json-api-nestjs'; +import type { AnyEntity } from '@klerick/json-api-nestjs-shared'; +import type { AclSubject } from './acl-rules.types'; +import { AclModuleOptions } from './acl-options.types'; + +/** + * Extract function property names from a type + */ +type FunctionPropertyNames = { + // eslint-disable-next-line @typescript-eslint/ban-types + [K in keyof T]: T[K] extends Function ? K : never; +}[keyof T]; + +/** + * Extract method names from controller class + */ +export type ControllerMethods = FunctionPropertyNames; + +export type AclControllerMethodsOptions = boolean | Omit; + +/** + * Partial record of controller methods with boolean values + * Typed by specific controller class + */ +export type ControllerMethodsConfig = Partial< + Record, AclControllerMethodsOptions> +>; + +/** + * Options for @AclController decorator + * Generic Controller type allows type-safe method configuration + */ +export interface AclControllerOptions< + E extends AnyEntity = AnyEntity, + Controller = JsonBaseController +> { + /** + * Subject for ACL checks + * Can be Entity class, instance, or string name + * + * @example + * subject: User + * subject: 'User' + */ + subject: AclSubject; + + /** + * Configuration for which controller methods should have ACL enabled + * If not specified, all methods are enabled by default + * Type-safe based on Controller generic parameter + * + * @example + * methods: { + * getAll: true, + * getOne: true, + * postOne: true, + * patchOne: false, // disabled + * deleteOne: false, // disabled + * } + */ + methods?: ControllerMethodsConfig; + + /** + * Whether ACL is enabled for this controller + * @default true + */ + enabled?: boolean; +} + +/** + * Metadata stored by @AclController decorator + */ +export interface AclControllerMetadata { + subject: AclSubject; + methods: Record; + enabled: boolean; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/index.ts new file mode 100644 index 00000000..fbeafab1 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/index.ts @@ -0,0 +1,4 @@ +export * from './acl-options.types' +export * from './acl-rules.types' +export * from './decorator-options.types' +export * from './acl-context.types' diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/index.ts new file mode 100644 index 00000000..29a4d796 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/index.ts @@ -0,0 +1,74 @@ +import { pascalCase } from 'change-case-commonjs'; +import { Injectable, PipeTransform, Type } from '@nestjs/common'; +import { AnyEntity, EntityClass } from '@klerick/json-api-nestjs-shared'; +import { AclControllerMethodsOptions, AclModuleOptions } from '../types'; + + +export * from './orm-proxy'; + +export function factoryPipeMixin( + entity: EntityClass, + pipe: Type +) { + const entityName = entity.name; + + const pipeClass = nameIt( + `${pascalCase(entityName)}${pipe.name}`, + pipe + ) as Type; + + Injectable()(pipeClass); + + return pipeClass; +} + +export const nameIt = ( + name: string, + cls: new (...rest: unknown[]) => Record +) => + ({ + [name]: class extends cls { + constructor(...arg: unknown[]) { + super(...arg); + } + }, + }[name]); + +export function copyMethodMetadata(source: Function, target: Function) { + const metadataKeys = Reflect.getMetadataKeys(source); + + for (const key of metadataKeys) { + const value = Reflect.getMetadata(key, source); + Reflect.defineMetadata(key, value, target); + } + + Object.defineProperty(target, 'name', { + value: source.name, + writable: false, + }); +} + +export function getActionOptions( + moduleOptions: AclModuleOptions, + actionOptions: AclControllerMethodsOptions +): Exclude { + const defaultOptions = { + onNoRules: moduleOptions.onNoRules, + defaultRules: moduleOptions.defaultRules, + }; + + if ( + actionOptions === undefined || + actionOptions === true || + actionOptions === false + ) + return defaultOptions; + + return { + ...defaultOptions, + ...{ + onNoRules: actionOptions.onNoRules, + defaultRules: actionOptions.defaultRules, + }, + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/extract-field-paths.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/extract-field-paths.spec.ts new file mode 100644 index 00000000..e62fc91c --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/extract-field-paths.spec.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ExtractFieldPaths } from './extract-field-paths'; + +describe('ExtractFieldPaths', () => { + let extractor: ExtractFieldPaths; + let entityParamMap: Map; + + // Mock entity classes + class User { + id?: number; + login?: string; + email?: string; + profile?: Profile; + comments?: Comment[]; + } + + class Profile { + id?: number; + phone?: string; + address?: Address; + } + + class Address { + id?: number; + city?: string; + street?: string; + } + + class Comment { + id?: number; + text?: string; + } + + beforeEach(() => { + // Reset singleton + (ExtractFieldPaths as any).instance = undefined; + + // Create mock EntityParamMap + entityParamMap = new Map(); + + // User metadata + entityParamMap.set(User, { + props: ['id', 'login', 'email'], + primaryColumnName: 'id', + relations: ['profile', 'comments'], + relationProperty: { + profile: { + entityClass: Profile, + }, + comments: { + entityClass: Comment, + }, + }, + }); + + // Profile metadata + entityParamMap.set(Profile, { + props: ['id', 'phone'], + primaryColumnName: 'id', + relations: ['address'], + relationProperty: { + address: { + entityClass: Address, + }, + }, + }); + + // Address metadata + entityParamMap.set(Address, { + props: ['id', 'city', 'street'], + primaryColumnName: 'id', + relations: [], + relationProperty: {}, + }); + + // Comment metadata + entityParamMap.set(Comment, { + props: ['id', 'text'], + primaryColumnName: 'id', + relations: [], + relationProperty: {}, + }); + + extractor = ExtractFieldPaths.getInstance(entityParamMap as any); + }); + + afterEach(() => { + // Clean up singleton + (ExtractFieldPaths as any).instance = undefined; + }); + + it('should extract simple fields', () => { + const user = new User(); + user.id = 1; + user.login = 'user1'; + user.email = 'user@example.com'; + + const fields = extractor.fields(user, User); + + // id is primary key, should be skipped + expect(fields).toEqual(['login', 'email']); + }); + + it('should extract fields with one-to-one relation', () => { + const profile = new Profile(); + profile.id = 10; + profile.phone = '123456'; + + const user = new User(); + user.id = 1; + user.login = 'user1'; + user.profile = profile; + + const fields = extractor.fields(user, User); + + // id and profile.id are primary keys, should be skipped + expect(fields).not.toContain('id'); + expect(fields).toContain('login'); + expect(fields).not.toContain('profile.id'); + expect(fields).toContain('profile.phone'); + expect(fields).toEqual(['login', 'profile.phone']); + }); + + it('should extract fields with one-to-many relation', () => { + const comment1 = new Comment(); + comment1.id = 1; + comment1.text = 'Comment 1'; + + const user = new User(); + user.id = 1; + user.login = 'user1'; + user.comments = [comment1]; + + const fields = extractor.fields(user, User); + + // id and comments.id are primary keys, should be skipped + expect(fields).not.toContain('id'); + expect(fields).toContain('login'); + expect(fields).not.toContain('comments.id'); + expect(fields).toContain('comments.text'); + expect(fields).toEqual(['login', 'comments.text']); + }); + + it('should extract nested relation fields', () => { + const address = new Address(); + address.id = 100; + address.city = 'New York'; + address.street = 'Main St'; + + const profile = new Profile(); + profile.id = 10; + profile.phone = '123456'; + profile.address = address; + + const user = new User(); + user.id = 1; + user.login = 'user1'; + user.profile = profile; + + const fields = extractor.fields(user, User); + + // All id fields are primary keys, should be skipped + expect(fields).not.toContain('id'); + expect(fields).toContain('login'); + expect(fields).not.toContain('profile.id'); + expect(fields).toContain('profile.phone'); + expect(fields).not.toContain('profile.address.id'); + expect(fields).toContain('profile.address.city'); + expect(fields).toContain('profile.address.street'); + expect(fields).toEqual(['login', 'profile.phone', 'profile.address.city', 'profile.address.street']); + }); + + it('should skip null relation', () => { + const user = new User(); + user.id = 1; + user.login = 'user1'; + user.profile = null as any; + + const fields = extractor.fields(user, User); + + // id is primary key, should be skipped + expect(fields).toEqual(['login']); + expect(fields).not.toContain('profile.id'); + }); + + it('should skip undefined relation', () => { + const user = new User(); + user.id = 1; + user.login = 'user1'; + + const fields = extractor.fields(user, User); + + // id is primary key, should be skipped + expect(fields).toEqual(['login']); + }); + + it('should skip empty array relation', () => { + const user = new User(); + user.id = 1; + user.login = 'user1'; + user.comments = []; + + const fields = extractor.fields(user, User); + + // id is primary key, should be skipped + expect(fields).toEqual(['login']); + }); + + it('should skip fields not present in object', () => { + const user = new User(); + user.id = 1; + // login and email not set + + const fields = extractor.fields(user, User); + + // id is primary key, but it's not in the object (not set explicitly after construction) + // Actually, we set it, so it IS in the object + // But it should be skipped as primary key, so result should be [] + expect(fields).toEqual([]); + expect(fields).not.toContain('id'); + expect(fields).not.toContain('login'); + expect(fields).not.toContain('email'); + }); + + it('should throw error if entity not found in EntityParamMap', () => { + class UnknownEntity { + id?: number; + } + + const obj = new UnknownEntity(); + obj.id = 1; + + expect(() => extractor.fields(obj, UnknownEntity)).toThrow('Entity UnknownEntity not found in EntityParamMap'); + }); + + it('should return singleton instance', () => { + const instance1 = ExtractFieldPaths.getInstance(entityParamMap as any); + const instance2 = ExtractFieldPaths.getInstance(entityParamMap as any); + + expect(instance1).toBe(instance2); + }); + + it('should handle complex nested structure', () => { + const address = new Address(); + address.id = 100; + address.city = 'New York'; + + const profile = new Profile(); + profile.id = 10; + profile.address = address; + + const comment1 = new Comment(); + comment1.id = 1; + comment1.text = 'Comment'; + + const user = new User(); + user.id = 1; + user.login = 'user1'; + user.email = 'user@example.com'; + user.profile = profile; + user.comments = [comment1]; + + const fields = extractor.fields(user, User); + + // All id fields are primary keys, should be skipped + expect(fields).not.toContain('id'); + expect(fields).toContain('login'); + expect(fields).toContain('email'); + expect(fields).not.toContain('profile.id'); + expect(fields).not.toContain('profile.address.id'); + expect(fields).toContain('profile.address.city'); + expect(fields).not.toContain('comments.id'); + expect(fields).toContain('comments.text'); + // Only 4 non-primary-key fields + expect(fields).toEqual(['login', 'email', 'profile.address.city', 'comments.text']); + expect(fields.length).toBe(4); + }); +}); \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/extract-field-paths.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/extract-field-paths.ts new file mode 100644 index 00000000..fb1de987 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/extract-field-paths.ts @@ -0,0 +1,230 @@ +import { + AnyEntity, + EntityClass, + QueryField, +} from '@klerick/json-api-nestjs-shared'; +import { + EntityParam, + EntityParamMap, + CURRENT_ENTITY, + ENTITY_PARAM_MAP, + QueryOne, + Query, +} from '@klerick/json-api-nestjs'; +import { ModuleRef } from '@nestjs/core'; +import { removeAclAddedFields } from './remove-acl-added-fields'; +import { handleAclQueryError } from './handle-acl-query-error'; + +/** + * Singleton class for extracting field paths from entity objects + * + * Features: + * - Skips primary keys (they are always accessible, no ACL check needed) + * - Recursively processes relationships + * - Returns flat array of dot-notation paths + * - Handles one-to-many (arrays) and one-to-one (objects) relationships + * + * @example + * const extractor = ExtractFieldPaths.getInstance(entityParamMap); + * const obj = { + * id: 1, // primary key - will be skipped + * login: 'user', + * profile: { id: 10, phone: '123' } // profile.id also skipped + * }; + * + * const paths = extractor.fields(obj, User); + * // Returns: ['login', 'profile.phone'] + */ +export class ExtractFieldPaths { + private constructor( + private entityParamMap: EntityParamMap> + ) {} + + private extractField( + obj: E, + nameEntity: EntityClass, + prefix = '' + ): string[] { + const fields: string[] = []; + const entityParam = this.entityParamMap.get(nameEntity) as + | EntityParam + | undefined; + if (!entityParam) { + throw new Error(`Entity ${nameEntity.name} not found in EntityParamMap`); + } + + // Add all properties (fields) + for (const prop of entityParam.props as (keyof E)[]) { + if (!(prop in obj) || entityParam.primaryColumnName === prop) { + continue; + } + + const path = (prefix ? `${prefix}.${prop.toString()}` : prop).toString(); + fields.push(path); + } + + for (const relation of entityParam.relations as (keyof EntityParam['relationProperty'])[]) { + const value = obj[relation]; + const path = ( + prefix ? `${prefix}.${relation.toString()}` : relation + ).toString(); + + if (value === null || value === undefined) { + continue; + } + + // Get relation metadata + const relationMeta = entityParam.relationProperty[relation]; + if (!relationMeta || !('entityClass' in relationMeta)) { + continue; + } + + // Handle arrays (one-to-many) + if (Array.isArray(value)) { + if ( + value.length > 0 && + typeof value[0] === 'object' && + value[0] !== null + ) { + const relObject = value[0]; + fields.push( + ...this.extractField(relObject, relationMeta.entityClass, path) + ); + } + continue; + } + + // Handle single object (one-to-one, many-to-one) + if (typeof value === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fields.push( + ...this.extractField(value as any, relationMeta.entityClass, path) + ); + } + } + + return fields; + } + + fields(obj: E, entityClass: EntityClass): string[] { + return this.extractField(obj, entityClass); + } + + /** + * Extracts only props (entity fields) from object, excluding relationships and primary key + * Skips primary key (same as fields() method) + * + * Use case: Create merged entity with only base fields for ACL checks + * + * @param obj - Source entity object (may contain loaded relationships) + * @param entityClass - Entity class + * @returns New object with only entity props (no relationships, no primary key) + * + * @example + * const entity = { id: 1, login: 'user', role: 'admin', profile: { phone: '123' } }; + * const propsOnly = extractor.props(entity, User); + * // Returns: { login: 'user', role: 'admin' } + * // Note: id (primary key) and profile (relationship) excluded + */ + props(obj: E, entityClass: EntityClass): Partial { + const entityParam = this.entityParamMap.get(entityClass) as + | EntityParam + | undefined; + + if (!entityParam) { + throw new Error(`Entity ${entityClass.name} not found in EntityParamMap`); + } + + const propsOnly: Partial = {}; + + // Copy all props (excluding primary key) + for (const prop of entityParam.props as (keyof E)[]) { + // Skip if prop not in object OR prop is primary key + if (!(prop in obj) || entityParam.primaryColumnName === prop) { + continue; + } + + propsOnly[prop] = obj[prop]; + } + + return propsOnly; + } + + private static instance: ExtractFieldPaths | undefined; + static getInstance( + entityParamMap: EntityParamMap> + ): ExtractFieldPaths { + if (!ExtractFieldPaths.instance) { + ExtractFieldPaths.instance = new ExtractFieldPaths(entityParamMap); + } + return ExtractFieldPaths.instance; + } +} + +export function getCurrentEntityAndParamMap(moduleRef: ModuleRef) { + const currentEntity = moduleRef.get>(CURRENT_ENTITY); + const entityParamMapService = moduleRef.get< + EntityParamMap> + >(ENTITY_PARAM_MAP, { + strict: false, + }); + const entityParamMap = entityParamMapService.get(currentEntity); + + if (!entityParamMap) { + throw handleAclQueryError( + new Error(`EntityParamMap not found for ${currentEntity.name}`), + currentEntity.name, + 'extractFieldsForCheck' + ); + } + + return { currentEntity, entityParamMap, entityParamMapService } as const; +} + +/** + * Extracts field paths for ACL checking + * + * This function: + * 1. Gets entity metadata from moduleRef + * 2. Clones the sample item + * 3. Removes ACL-added fields that weren't requested by user + * 4. Extracts field paths for checking + * + * @param moduleRef - NestJS module reference + * @param sampleItem - Sample entity item (first item for getAll, result for getOne) + * @param userQuery - Original user query + * @param aclQueryData - ACL query data (fields and include that were added by ACL) + * @returns Array of field paths to check + */ +export function extractFieldsForCheck< + E extends object, + IdKey extends string, + Q extends QueryOne | Query +>( + moduleRef: ModuleRef, + sampleItem: E, + userQuery: Q, + aclQueryData?: { + fields?: Q[QueryField.fields]; + include?: Q[QueryField.include]; + rulesForQuery?: Record; + } +): string[] { + const { currentEntity, entityParamMapService } = getCurrentEntityAndParamMap(moduleRef); + const copyItemForGetFieldCheck = structuredClone(sampleItem); + + if (aclQueryData) { + removeAclAddedFields( + copyItemForGetFieldCheck, + userQuery['fields'], + aclQueryData.fields, + userQuery['include'], + aclQueryData.include + ); + } + + return ExtractFieldPaths.getInstance(entityParamMapService).fields( + copyItemForGetFieldCheck, + currentEntity + ); +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/handle-acl-query-error.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/handle-acl-query-error.ts new file mode 100644 index 00000000..7729e78b --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/handle-acl-query-error.ts @@ -0,0 +1,76 @@ +import { + ForbiddenException, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { HttpException } from '@nestjs/common/exceptions/http.exception'; + +/** + * Handles errors that occur during ACL query execution + * + * In development mode: + * - Logs detailed error information + * - Throws InternalServerErrorException with error details + * + * In production mode: + * - Logs error without details + * - Throws ForbiddenException without revealing ACL logic + * + * @param error - The error that occurred + * @param subject - The subject (entity name) being queried + * @param methodName - The proxy method name (for logging context) + * @return {InternalServerErrorException} In development mode + * @return {ForbiddenException} In production mode + */ +export function handleAclQueryError( + error: unknown, + subject: string, + methodName: string +): HttpException { + const isDevelopment = process.env['NODE_ENV'] === 'development'; + + // Log error for debugging + Logger.error( + `ACL query execution failed for subject "${subject}": ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + methodName + ); + + if (error instanceof HttpException) { + throw error + } + + if (isDevelopment) { + // Development: 500 with error details for debugging + return new InternalServerErrorException( + [ + { + code: 'internal_server_error', + message: `ACL query failed: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + path: [], + }, + ], + { + description: `Failed to execute ACL query for subject "${subject}"`, + } + ); + } + + // Production: 403 without details to avoid leaking ACL logic + return new ForbiddenException( + [ + { + code: 'forbidden', + message: 'not allow access', + path: [], + }, + ], + { + description: `Access denied for subject "${subject}"`, + } + ); +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/index.ts new file mode 100644 index 00000000..2db881fb --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/index.ts @@ -0,0 +1,9 @@ +export { unsetDeep } from './unset-deep'; +export { mergeQueryWithAclData } from './merge-query-with-acl-data'; +export { removeAclAddedFields } from './remove-acl-added-fields'; +export { ExtractFieldPaths, extractFieldsForCheck, getCurrentEntityAndParamMap } from './extract-field-paths'; +export { handleAclQueryError } from './handle-acl-query-error'; +export { prepareAclQuery } from './prepare-acl-query'; +export { processItemFieldRestrictions } from './process-item-field-restrictions'; +export { validateRulesForORM } from './validate-rules-for-orm'; +export { validateNoCurrentInRules } from './validate-no-current-in-rules'; \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/merge-query-with-acl-data.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/merge-query-with-acl-data.spec.ts new file mode 100644 index 00000000..7db6b6a8 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/merge-query-with-acl-data.spec.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest'; +import { mergeQueryWithAclData } from './merge-query-with-acl-data'; + +describe('mergeQueryWithAclData', () => { + describe('basic cases', () => { + it('should return unchanged query if no ACL data provided', () => { + const query = { + fields: { target: ['id', 'login'] }, + include: ['profile'], + } as any; + + const result = mergeQueryWithAclData(query, null, []); + + expect(result.fields).toEqual(query.fields); + expect(result.include).toEqual(query.include); + }); + + it('should merge target fields without duplicates', () => { + const query = { + fields: { target: ['id', 'login'] }, + include: [], + } as any; + + const aclFields = { target: ['login', 'email'] }; + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + expect(result.fields?.target).toEqual(['id', 'login', 'email']); + }); + + it('should merge relation fields without duplicates', () => { + const query = { + fields: { + target: ['id'], + profile: ['id', 'name'], + }, + include: ['profile'], + } as any; + + const aclFields = { profile: ['name', 'isPublic'] }; + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + // @ts-ignore + expect(result.fields?.profile).toEqual(['id', 'name', 'isPublic']); + }); + + it('should merge includes without duplicates', () => { + const query = { + fields: { target: ['id'] }, + include: ['profile', 'comments'], + } as any; + + const aclInclude = ['comments', 'roles']; + + const result = mergeQueryWithAclData(query, null, aclInclude as any); + + expect(result.include).toEqual(['profile', 'comments', 'roles']); + }); + }); + + describe('fields: null cases (select ALL fields)', () => { + it('should NOT add ACL fields when query.fields is null', () => { + const query = { + fields: null, + include: [], + } as any; + + const aclFields = { target: ['role'], profile: ['isPublic'] }; + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + // fields should remain null (all fields already selected) + expect(result.fields).toBeNull(); + }); + + it('should merge includes even when fields is null', () => { + const query = { + fields: null, + include: ['profile'], + } as any; + + const aclFields = { target: ['role'] }; + const aclInclude = ['comments']; + + const result = mergeQueryWithAclData(query, aclFields as any, aclInclude as any); + + expect(result.fields).toBeNull(); + expect(result.include).toEqual(['profile', 'comments']); + }); + }); + + describe('fields: undefined cases (select ALL fields)', () => { + it('should NOT add ACL fields when query.fields is undefined', () => { + const query = { + include: [], + } as any; + + const aclFields = { target: ['role'], profile: ['isPublic'] }; + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + expect(result.fields).toBeUndefined(); + }); + }); + + describe('fields: {} cases (empty object = ALL fields)', () => { + it('should NOT add ACL fields when query.fields is empty object', () => { + const query = { + fields: {}, + include: [], + } as any; + + const aclFields = { target: ['role'], profile: ['isPublic'] }; + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + expect(result.fields).toEqual({}); + }); + }); + + describe('missing relation key cases (relation not in query.fields = ALL fields for that relation)', () => { + it('should NOT add ACL fields for missing relation key', () => { + const query = { + fields: { target: ['id'] }, + include: ['profile'], + } as any; + + const aclFields = { profile: ['isPublic'] }; // profile not in query.fields + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + expect(result.fields).toEqual({ + target: ['id'], + // profile should NOT be added (missing key = all fields) + }); + }); + + it('should add ACL fields to target but NOT to missing relations', () => { + const query = { + fields: { target: ['id'] }, + include: ['profile'], + } as any; + + const aclFields = { target: ['role'], comments: ['id'] }; + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + expect(result.fields?.target).toEqual(['id', 'role']); + // @ts-ignore + expect(result.fields?.comments).toBeUndefined(); // NOT added + }); + }); + + describe('relation: null cases (specific relation = ALL fields)', () => { + it('should NOT add ACL fields when relation is null', () => { + const query = { + fields: { + target: ['id'], + profile: null, // ALL fields for profile + }, + include: ['profile'], + } as any; + + const aclFields = { target: ['role'], profile: ['isPublic'] }; + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + expect(result.fields?.target).toEqual(['id', 'role']); + // @ts-ignore + expect(result.fields?.profile).toBeNull(); // unchanged + }); + }); + + describe('complex scenarios', () => { + it('should merge both fields and includes correctly', () => { + const query = { + fields: { target: ['id'], profile: ['phone'] }, + include: ['profile'], + } as any; + + const aclFields = { target: ['role'], profile: ['isPublic'] }; + const aclInclude = ['comments']; + + const result = mergeQueryWithAclData(query, aclFields as any, aclInclude as any); + + expect(result.fields?.target).toEqual(['id', 'role']); + // @ts-ignore + expect(result.fields?.profile).toEqual(['phone', 'isPublic']); + expect(result.include).toEqual(['profile', 'comments']); + }); + + it('should handle mix of defined and undefined relation keys', () => { + const query = { + fields: { + target: ['id', 'login'], + profile: ['phone'], + // comments missing = all fields + }, + include: ['profile', 'comments'], + } as any; + + const aclFields = { + target: ['role'], + profile: ['isPublic'], + comments: ['text'], // should NOT be added (missing in query) + }; + + const result = mergeQueryWithAclData(query, aclFields as any, []); + + expect(result.fields?.target).toEqual(['id', 'login', 'role']); + // @ts-ignore + expect(result.fields?.profile).toEqual(['phone', 'isPublic']); + // @ts-ignore + expect(result.fields?.comments).toBeUndefined(); // NOT added + }); + }); +}); \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/merge-query-with-acl-data.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/merge-query-with-acl-data.ts new file mode 100644 index 00000000..50e427da --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/merge-query-with-acl-data.ts @@ -0,0 +1,100 @@ +import { Query, QueryOne } from '@klerick/json-api-nestjs'; +import { QueryField } from '@klerick/json-api-nestjs-shared'; +/** + * Merges user query with ACL fields and includes + * + * IMPORTANT: fields: null or missing relation key means "select ALL fields" + * We should NOT add ACL fields in such cases to avoid turning "all fields" into "specific fields" + * + * @param query - Original user query + * @param aclFields - ACL fields to add (fields structure with target and relations) + * @param aclInclude - ACL includes to add + * @returns Merged query + * + * @example + * // Case 1: fields: null → select all, don't add ACL fields + * mergeQueryWithAclData({ fields: null }, { target: ['role'] }) + * // → { fields: null } (unchanged) + * + * // Case 2: fields: { target: ['id'] } → add ACL fields to target + * mergeQueryWithAclData({ fields: { target: ['id'] } }, { target: ['role'] }) + * // → { fields: { target: ['id', 'role'] } } + * + * // Case 3: fields: { target: ['id'] } + ACL needs profile → don't add profile fields + * mergeQueryWithAclData({ fields: { target: ['id'] } }, { profile: ['isPublic'] }) + * // → { fields: { target: ['id'] } } (profile missing = all fields) + */ +export function mergeQueryWithAclData< + E extends object, + IdKey extends string, + Q extends QueryOne | Query +>( + query: Q, + aclFields?: Q[QueryField.fields], + aclInclude?: Q[QueryField.include] +): Q { + // Start with merged query + const mergedQuery = { ...query }; + + // 1. Always merge includes (add ACL includes to user includes) + if ( + aclInclude && + 'length' in aclInclude && + parseInt(`${aclInclude.length}`) > 0 + ) { + const userInclude = Array.isArray(query.include) ? query.include : []; + const aclIncludeArray = Array.isArray(aclInclude) ? aclInclude : []; + mergedQuery.include = Array.from( + new Set([...userInclude, ...aclIncludeArray]) + ) as any; + } + + // 2. Merge fields (complex logic for null/undefined handling) + if (!aclFields) { + return mergedQuery; // No ACL fields to merge + } + + // CASE 1: fields === null → "select ALL fields everywhere" + if (query.fields === null) { + return mergedQuery; // Don't modify, null already includes all ACL fields + } + + // CASE 2: fields === undefined → "select ALL fields everywhere" + if (query.fields === undefined) { + return mergedQuery; // Don't modify + } + + // CASE 3: fields === {} (empty object) → "select ALL fields everywhere" + if (Object.keys(query.fields).length === 0) { + return mergedQuery; // Don't modify + } + + // CASE 4: fields is object with keys → merge selectively + mergedQuery.fields = { ...query.fields }; + + for (const [relation, aclFieldsList] of Object.entries(aclFields)) { + if (!Array.isArray(aclFieldsList)) { + continue; // Skip invalid ACL fields + } + + const userFieldsList = (query.fields as any)[relation]; + + // Sub-case 1: relation key missing in user fields → "all fields for this relation" + if (userFieldsList === undefined) { + continue; // Don't add ACL fields, user wants all fields for this relation + } + + // Sub-case 2: relation: null → "all fields for this relation" + if (userFieldsList === null) { + continue; // Don't add ACL fields + } + + // Sub-case 3: relation is array → merge with ACL fields + if (Array.isArray(userFieldsList)) { + const merged = [...new Set([...userFieldsList, ...aclFieldsList])]; + (mergedQuery.fields as any)[relation] = merged; + } + } + + return mergedQuery; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/prepare-acl-query.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/prepare-acl-query.ts new file mode 100644 index 00000000..3eed47e7 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/prepare-acl-query.ts @@ -0,0 +1,49 @@ +import { ExtendAbility } from '../../factories'; +import { mergeQueryWithAclData } from './merge-query-with-acl-data'; +import { validateRulesForORM } from './validate-rules-for-orm'; +import { Query, QueryOne } from '@klerick/json-api-nestjs'; + +export function prepareAclQuery | Query>( + extendAbility: ExtendAbility, + query: Q, + needValidateRules = true +) { + // Fast path: no rules or no restrictions + if ( + !extendAbility || + extendAbility.rules.length === 0 || + (!extendAbility.hasConditions && !extendAbility.hasFields) + ) { + return null; + } + + // Determine strategy + const hasConditions = extendAbility.hasConditions; + const hasFields = extendAbility.hasFields; + const transformToJsonApi = hasConditions && !hasFields; + + // Validate rules for ORM compatibility if there are conditions + if (needValidateRules && hasConditions) { + validateRulesForORM(extendAbility); + } + + // Fetch with ACL query + const aclQueryData = hasConditions + ? extendAbility.getQueryObject() + : undefined; + + // Merge ACL query with user query + const mergedQuery = aclQueryData + ? mergeQueryWithAclData( + query, + aclQueryData.fields, + aclQueryData.include + ) + : query; + + return { + transformToJsonApi, + aclQueryData, + mergedQuery, + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/process-item-field-restrictions.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/process-item-field-restrictions.ts new file mode 100644 index 00000000..f3ba4bcd --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/process-item-field-restrictions.ts @@ -0,0 +1,72 @@ +import { subject as subjectAbility } from '@casl/ability'; +import { ExtendAbility } from '../../factories'; +import { unsetDeep } from './unset-deep'; +import { removeAclAddedFields } from './remove-acl-added-fields'; +import { QueryField } from '@klerick/json-api-nestjs-shared'; +import { Query, QueryOne } from '@klerick/json-api-nestjs'; + +/** + * Processes field restrictions for a single item + * + * This function: + * 1. Updates ability with item data for @input templates + * 2. Checks each field against ACL rules + * 3. Removes restricted fields from the item + * 4. Removes ACL-added fields that weren't requested by user + * + * @param item - Entity item to process (mutated in place) + * @param fieldsForCheck - Array of field paths to check + * @param extendAbility - ExtendAbility instance for permission checking + * @param query - Original user query + * @param aclQueryData - ACL query data (fields and include added by ACL) + * @returns Array of restricted field names + */ +export function processItemFieldRestrictions< + E extends object, + IdKey extends string, + Q extends QueryOne | Query +>( + item: E, + fieldsForCheck: string[], + extendAbility: ExtendAbility, + query: Q, + aclQueryData?: { + fields?: Q[QueryField.fields]; + include?: Q[QueryField.include]; + rulesForQuery?: Record; + } +): string[] { + // Update ability with item data for @input templates + extendAbility.updateWithInput(item); + + const currentAction = extendAbility.action; + const restrictedFieldsForItem: string[] = []; + + // Check each field + for (const field of fieldsForCheck) { + if ( + !extendAbility.can( + currentAction, + subjectAbility(extendAbility.subject, item), + field + ) + ) { + // Remove field from item + unsetDeep(item, field); + restrictedFieldsForItem.push(field); + } + } + + // Remove ACL-added fields and relations that were not requested by user + if (aclQueryData) { + removeAclAddedFields( + item, + query['fields'], + aclQueryData.fields, + query['include'], + aclQueryData.include + ); + } + + return restrictedFieldsForItem; +} \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/remove-acl-added-fields.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/remove-acl-added-fields.spec.ts new file mode 100644 index 00000000..a4d92c36 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/remove-acl-added-fields.spec.ts @@ -0,0 +1,374 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { removeAclAddedFields } from './remove-acl-added-fields'; + +describe('removeAclAddedFields', () => { + let item: any; + + beforeEach(() => { + item = { + id: 1, + login: 'user1', + email: 'user@example.com', + role: 'admin', + profile: { + id: 10, + phone: '123456', + isPublic: true, + }, + comments: [{ id: 1, text: 'Comment 1' }], + }; + }); + + describe('basic cases', () => { + it('should do nothing if no ACL fields provided', () => { + const originalItem = JSON.parse(JSON.stringify(item)); + + removeAclAddedFields(item, { target: ['id'] } as any, null); + + expect(item).toEqual(originalItem); + }); + + it('should remove target fields added by ACL', () => { + const userFields = { target: ['id', 'login'] }; + const aclFields = { target: ['role', 'email'] }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + expect(item).toEqual({ + id: 1, + login: 'user1', + profile: { + id: 10, + phone: '123456', + isPublic: true, + }, + comments: [{ id: 1, text: 'Comment 1' }], + }); + expect(item.email).toBeUndefined(); + expect(item.role).toBeUndefined(); + }); + + it('should remove specific relation fields added by ACL', () => { + const userFields = { + target: ['id'], + profile: ['id', 'phone'], + }; + const aclFields = { + profile: ['phone', 'isPublic'], + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + expect(item.profile).toEqual({ + id: 10, + phone: '123456', + }); + expect(item.profile.isPublic).toBeUndefined(); + }); + + it('should keep fields that were requested by user', () => { + const userFields = { + target: ['id', 'login', 'role'], + }; + const aclFields = { + target: ['role', 'email'], + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + expect(item.id).toBe(1); + expect(item.login).toBe('user1'); + expect(item.role).toBe('admin'); + expect(item.email).toBeUndefined(); + }); + + it('should handle nested relation fields', () => { + const userFields = { + target: ['id'], + profile: ['id'], + }; + const aclFields = { + target: ['role'], + profile: ['id', 'phone', 'isPublic'], + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + expect(item.profile).toEqual({ id: 10 }); + expect(item.profile.phone).toBeUndefined(); + expect(item.profile.isPublic).toBeUndefined(); + expect(item.role).toBeUndefined(); + }); + }); + + describe('userFields: null cases (all fields requested)', () => { + it('should NOT remove anything when userFields is null', () => { + const originalItem = JSON.parse(JSON.stringify(item)); + const aclFields = { + target: ['role', 'email'], + profile: ['isPublic'], + }; + + removeAclAddedFields(item, null, aclFields as any); + + // Nothing should be removed (null = all fields requested) + expect(item).toEqual(originalItem); + expect(item.role).toBe('admin'); + expect(item.email).toBe('user@example.com'); + expect(item.profile.isPublic).toBe(true); + }); + }); + + describe('userFields: undefined cases (all fields requested)', () => { + it('should NOT remove anything when userFields is undefined', () => { + const originalItem = JSON.parse(JSON.stringify(item)); + const aclFields = { + target: ['role', 'email'], + profile: ['isPublic'], + }; + + // @ts-ignore + removeAclAddedFields(item, undefined, aclFields as any); + + // Nothing should be removed + expect(item).toEqual(originalItem); + }); + }); + + describe('userFields: {} cases (empty object = all fields)', () => { + it('should NOT remove anything when userFields is empty object', () => { + const originalItem = JSON.parse(JSON.stringify(item)); + const aclFields = { + target: ['role'], + profile: ['isPublic'], + }; + + removeAclAddedFields(item, {} as any, aclFields as any); + + // Nothing should be removed + expect(item).toEqual(originalItem); + }); + }); + + describe('missing relation key cases (all fields for that relation)', () => { + it('should NOT remove relation when key is missing from userFields', () => { + const userFields = { + target: ['id', 'login'], + // profile missing = all profile fields requested + }; + const aclFields = { + profile: ['isPublic'], + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + // profile should NOT be removed + expect(item.profile).toBeDefined(); + expect(item.profile.isPublic).toBe(true); + expect(item.id).toBe(1); + expect(item.login).toBe('user1'); + }); + + it('should handle mix of present and missing relation keys', () => { + const userFields = { + target: ['id'], + profile: ['phone'], + // comments missing = all comments fields requested + }; + const aclFields = { + target: ['role'], + profile: ['isPublic'], + comments: ['text'], + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + // role removed (target has array) + expect(item.role).toBeUndefined(); + + // profile.isPublic removed (profile has array) + expect(item.profile).toEqual({ id: 10, phone: '123456' }); + + // comments NOT removed (missing key = all fields) + expect(item.comments).toEqual([{ id: 1, text: 'Comment 1' }]); + }); + }); + + describe('relation: null cases (all fields for that relation)', () => { + it('should NOT remove fields when relation is null', () => { + const userFields = { + target: ['id'], + profile: null as any, // all profile fields requested + }; + const aclFields = { + target: ['role'], + profile: ['isPublic', 'phone'], + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + // role removed (target has array) + expect(item.role).toBeUndefined(); + + // profile fields NOT removed (profile: null) + expect(item.profile).toEqual({ + id: 10, + phone: '123456', + isPublic: true, + }); + }); + + it('should handle mix of array and null relations', () => { + const userFields = { + target: ['id', 'login'], + profile: null as any, + comments: ['id'], + }; + const aclFields = { + target: ['role'], + profile: ['isPublic'], + comments: ['id', 'text'], + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + // role removed (target array, not requested) + expect(item.role).toBeUndefined(); + + // profile untouched (null) + expect(item.profile).toEqual({ + id: 10, + phone: '123456', + isPublic: true, + }); + + // comments.text removed (array, not requested) + expect(item.comments).toEqual([{ id: 1 }]); + }); + }); + + describe('complex scenarios', () => { + it('should handle all cases mixed together', () => { + const userFields = { + target: ['id', 'login'], // array + profile: null as any, // null = all fields + // comments missing // undefined = all fields + }; + const aclFields = { + target: ['role', 'email'], // should remove both + profile: ['isPublic'], // should NOT remove + comments: ['text'], // should NOT remove + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + expect(item).toEqual({ + id: 1, + login: 'user1', + // role, email removed (not in target array) + profile: { + id: 10, + phone: '123456', + isPublic: true, // kept (profile: null) + }, + comments: [{ id: 1, text: 'Comment 1' }], // kept (missing key) + }); + }); + + it('should keep requested relations with their fields', () => { + const userFields = { + target: ['id'], + profile: ['phone'], + comments: ['text'], // User requested only 'text' + }; + const aclFields = { + profile: ['isPublic'], + comments: ['id'], // ACL added 'id' for checks + }; + + removeAclAddedFields(item, userFields as any, aclFields as any); + + expect(item.profile).toEqual({ + id: 10, + phone: '123456', + }); + expect(item.profile.isPublic).toBeUndefined(); + // 'id' should be removed (not requested by user, added by ACL) + expect(item.comments).toEqual([{ text: 'Comment 1' }]); + }); + }); + + describe('remove ACL-added relations via include', () => { + it('should remove relation added by ACL include', () => { + const userFields = null; // All fields + const userInclude: any[] = []; // NO includes requested by user + const aclInclude = ['profile']; // ACL added profile for conditions check + + // @ts-ignore + removeAclAddedFields(item, userFields, null, userInclude, aclInclude as any); + + // profile should be removed (not requested by user) + expect(item.profile).toBeUndefined(); + expect(item.id).toBe(1); + expect(item.login).toBe('user1'); + }); + + it('should keep relation requested by user via include', () => { + const userFields = null; + const userInclude = ['profile']; // User requested profile + const aclInclude = ['profile', 'comments']; // ACL added profile + comments + + removeAclAddedFields(item, userFields, null, userInclude as any, aclInclude as any); + + // profile should be kept (requested by user) + expect(item.profile).toBeDefined(); + expect(item.profile.phone).toBe('123456'); + + // comments should be removed (not requested by user) + expect(item.comments).toBeUndefined(); + }); + + it('should remove multiple ACL-added relations', () => { + const userFields = null; + const userInclude: any[] = []; + const aclInclude = ['profile', 'comments']; + + // @ts-ignore + removeAclAddedFields(item, userFields, null, userInclude, aclInclude as any); + + expect(item.profile).toBeUndefined(); + expect(item.comments).toBeUndefined(); + expect(item.id).toBe(1); + expect(item.login).toBe('user1'); + }); + + it('should work with fields and includes together', () => { + const userFields = { target: ['id'] }; + const aclFields = { target: ['role'], profile: ['isPublic'] }; + const userInclude: any[] = []; // NO includes + const aclInclude = ['profile']; // ACL added profile + + // @ts-ignore + removeAclAddedFields(item, userFields as any, aclFields as any, userInclude, aclInclude as any); + + // role removed (field-level) + expect(item.role).toBeUndefined(); + + // profile removed entirely (relation-level) + expect(item.profile).toBeUndefined(); + + expect(item.id).toBe(1); + expect(item.login).toBe('user1'); + }); + + it('should handle undefined userInclude as empty array', () => { + const userFields = null; + const aclInclude = ['profile']; + + removeAclAddedFields(item, userFields, null, undefined, aclInclude as any); + + // profile should be removed (userInclude undefined = []) + expect(item.profile).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/remove-acl-added-fields.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/remove-acl-added-fields.ts new file mode 100644 index 00000000..8bc48436 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/remove-acl-added-fields.ts @@ -0,0 +1,121 @@ +import { Query, QueryOne } from '@klerick/json-api-nestjs'; +import { QueryField } from '@klerick/json-api-nestjs-shared'; +import { unsetDeep } from './unset-deep'; + +/** + * Removes ACL-added fields and relations from item that were not requested by user + * + * IMPORTANT: Must match the logic of mergeQueryWithAclData! + * Only removes fields/relations that were ACTUALLY ADDED by mergeQueryWithAclData. + * + * mergeQueryWithAclData adds ACL fields ONLY when userFields[relation] is an array. + * It does NOT add when: null, undefined, {}, missing key, or relation: null. + * + * @param item - Entity item to clean + * @param userFields - Fields requested by user (query.fields) + * @param aclFields - Fields added by ACL (aclQueryData.fields) + * @param userInclude - Relations requested by user (query.include) + * @param aclInclude - Relations added by ACL (aclQueryData.include) + * + * @example + * // Case 1: userFields: null → ACL didn't add fields, don't remove + * removeAclAddedFields(item, null, { target: ['role'] }) + * // → nothing removed (null = all fields requested) + * + * // Case 2: userFields: { target: ['id'] } → ACL added 'role', remove it + * removeAclAddedFields(item, { target: ['id'] }, { target: ['role'] }) + * // → removes 'role' from item + * + * // Case 3: ACL added include → remove entire relation + * removeAclAddedFields(item, null, null, [], ['profile']) + * // → removes profile relation (not requested by user) + */ +export function removeAclAddedFields< + E extends object, + IdKey extends string, + Q extends QueryOne | Query +>( + item: E, + userFields?: Q[QueryField.fields], + aclFields?: Q[QueryField.fields], + userInclude?: Q[QueryField.include], + aclInclude?: Q[QueryField.include] +): void { + // Remove relations added by ACL include (independent of fields logic) + if ( + aclInclude && + 'length' in aclInclude && + parseInt(`${aclInclude.length}`) > 0 + ) { + const userIncludeArray = (Array.isArray(userInclude) ? userInclude : []) as string[]; + const aclIncludeArray = (Array.isArray(aclInclude) ? aclInclude : []) as string[]; + for (const relation of aclIncludeArray) { + // If relation was added by ACL (not in user include), remove it + if (!userIncludeArray.includes(relation)) { + delete (item as any)[relation]; + } + } + } + + if (!aclFields) { + return; + } + + // CASE 1: userFields === null → all fields requested, ACL didn't add field-level data + if (userFields === null) { + return; // Don't remove fields (but relations already removed above) + } + + // CASE 2: userFields === undefined → all fields requested + if (userFields === undefined) { + return; // Don't remove fields + } + + // CASE 3: userFields === {} → all fields requested + if (typeof userFields === 'object' && Object.keys(userFields).length === 0) { + return; // Don't remove fields + } + + // CASE 4: userFields is object with keys + for (const [relation, aclFieldsArray] of Object.entries(aclFields)) { + if (!Array.isArray(aclFieldsArray)) continue; + + const userFieldsArray = (userFields as any)[relation]; + + // Sub-case 1: relation key missing → all fields for this relation requested + if (userFieldsArray === undefined) { + continue; // ACL didn't add fields, don't remove + } + + // Sub-case 2: relation: null → all fields for this relation requested + if (userFieldsArray === null) { + continue; // ACL didn't add fields, don't remove + } + + // Sub-case 3: relation is array → ACL MAY have added fields, check and remove + if (Array.isArray(userFieldsArray)) { + for (const field of aclFieldsArray) { + if (!userFieldsArray.includes(field)) { + // This field was added by ACL - remove it + if (relation === 'target') { + unsetDeep(item, field); + } else { + // Check if relation is an array (one-to-many) + const relationValue = (item as any)[relation]; + if (Array.isArray(relationValue)) { + // Remove field from each element in the array + for (const element of relationValue) { + if (typeof element === 'object' && element !== null) { + unsetDeep(element, field); + } + } + } else { + // Single object (one-to-one) - remove nested field + unsetDeep(item, `${relation}.${field}`); + } + } + } + } + } + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/unset-deep.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/unset-deep.spec.ts new file mode 100644 index 00000000..0710ebfe --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/unset-deep.spec.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { unsetDeep } from './unset-deep'; + +describe('unsetDeep', () => { + it('should remove simple property', () => { + const obj = { a: 1, b: 2 }; + unsetDeep(obj, 'a'); + + expect(obj).toEqual({ b: 2 }); + expect(obj.a).toBeUndefined(); + }); + + it('should remove nested property', () => { + const obj = { a: { b: { c: 1 } } }; + unsetDeep(obj, 'a.b.c'); + + expect(obj).toEqual({ a: { b: {} } }); + }); + + it('should remove deeply nested property', () => { + const obj = { + user: { + profile: { + address: { + city: 'New York', + street: 'Main St', + }, + }, + }, + }; + + unsetDeep(obj, 'user.profile.address.street'); + + expect(obj).toEqual({ + user: { + profile: { + address: { + city: 'New York', + }, + }, + }, + }); + }); + + it('should do nothing if path does not exist', () => { + const obj = { a: { b: 1 } }; + const original = JSON.parse(JSON.stringify(obj)); + + unsetDeep(obj, 'a.c.d'); + + expect(obj).toEqual(original); + }); + + it('should do nothing if intermediate path is null', () => { + const obj = { a: null, b: 2 } as any; + const original = JSON.parse(JSON.stringify(obj)); + + unsetDeep(obj, 'a.b.c'); + + expect(obj).toEqual(original); + }); + + it('should do nothing if intermediate path is undefined', () => { + const obj = { a: undefined, b: 2 } as any; + const original = JSON.parse(JSON.stringify(obj)); + + unsetDeep(obj, 'a.b.c'); + + expect(obj).toEqual(original); + }); + + it('should do nothing if intermediate path is primitive', () => { + const obj = { a: 'string', b: 2 } as any; + const original = JSON.parse(JSON.stringify(obj)); + + unsetDeep(obj, 'a.b.c'); + + expect(obj).toEqual(original); + }); + + it('should handle empty path', () => { + const obj = { a: 1 }; + const original = JSON.parse(JSON.stringify(obj)); + + unsetDeep(obj, ''); + + expect(obj).toEqual(original); + }); + + it('should do nothing if obj is null', () => { + expect(() => unsetDeep(null as any, 'a.b')).not.toThrow(); + }); + + it('should do nothing if obj is undefined', () => { + expect(() => unsetDeep(undefined as any, 'a.b')).not.toThrow(); + }); + + it('should do nothing if obj is not an object', () => { + expect(() => unsetDeep('string' as any, 'a.b')).not.toThrow(); + expect(() => unsetDeep(123 as any, 'a.b')).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/unset-deep.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/unset-deep.ts new file mode 100644 index 00000000..44d03add --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/unset-deep.ts @@ -0,0 +1,43 @@ +/** + * Removes a property from an object using dot notation path + * Similar to lodash.unset() + * + * @example + * const obj = { a: { b: { c: 1 } } }; + * unsetDeep(obj, 'a.b.c'); + * // obj = { a: { b: {} } } + * + * @param obj - The object to modify + * @param path - The path of the property to unset (e.g., 'profile.phone') + */ +export function unsetDeep(obj: T, path: string): void { + if (!obj || typeof obj !== 'object') { + return; + } + + const keys = path.split('.'); + + // Navigate to parent object + let current: any = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + + if (current[key] === null || current[key] === undefined) { + // Path doesn't exist, nothing to unset + return; + } + + if (typeof current[key] !== 'object') { + // Path is invalid (trying to access property of primitive) + return; + } + + current = current[key]; + } + + // Delete the final property + const lastKey = keys[keys.length - 1]; + if (current && typeof current === 'object' && lastKey in current) { + delete current[lastKey]; + } +} \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-no-current-in-rules.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-no-current-in-rules.ts new file mode 100644 index 00000000..4e4c88de --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-no-current-in-rules.ts @@ -0,0 +1,37 @@ +import { ExtendAbility } from '../../factories'; +import { handleAclQueryError } from './handle-acl-query-error'; + +/** + * Validates that ACL rules don't contain __current field references + * + * The __current field is only supported in operations that compare old vs new values: + * - patchOne + * - patchRelationship + * + * Other operations (getAll, getOne, postOne, deleteOne, etc.) should not use __current + * as there is no "old value" to compare against. + * + * Uses fast string search to check for __current references in rule conditions. + * Throws on first found __current reference (fail-fast approach) + * + * @param ability - ExtendAbility instance to validate + * @param context - Context name for error message (e.g., 'getOneProxy', 'postOneProxy') + * @throws Error if __current is found in rules + */ +export function validateNoCurrentInRules( + ability: ExtendAbility, + context: string +): void { + // Fast check: convert to JSON and search for __current references + const rulesJson = JSON.stringify(ability.rules); + + // Search for __current as field reference: "__current" + if (rulesJson.includes('"__current')) { + const error = new Error( + `Field __current is not supported in ${ability.action} operation. ` + + `__current is only available in patchOne and patchRelationship operations ` + + `where old and new values need to be compared.` + ); + throw handleAclQueryError(error, ability.subject, context); + } +} \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-rules-for-orm.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-rules-for-orm.spec.ts new file mode 100644 index 00000000..0aee1800 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-rules-for-orm.spec.ts @@ -0,0 +1,317 @@ +import { HttpException } from '@nestjs/common'; +import { validateRulesForORM } from './validate-rules-for-orm'; +import { AclRule } from '../../types'; +import { ExtendAbility } from '../../factories'; +import { RuleMaterializer } from '../../services'; + + +describe('validateRulesForORM', () => { + const subject = 'Post'; + const action = 'getAll'; + const materialize = new RuleMaterializer(); + + it('should pass validation for rules without unsupported operators', () => { + + const validRules: AclRule[] = [ + { + action, + subject, + conditions: { + // @ts-ignore + authorId: { $eq: 123 }, + age: { $gte: 18, $lte: 65 }, + status: { $in: ['published', 'archived'] }, + }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + validRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).not.toThrow(); + }); + + it('should pass validation for rules with $or/$and/$not', () => { + const validRules: AclRule[] = [ + { + action, + subject, + conditions: { + // @ts-ignore + $or: [ + { authorId: 123 }, + { $and: [{ verified: true }, { $not: { banned: true } }] }, + ], + }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + validRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).not.toThrow(); + }); + + it('should pass validation for rules with transformable operators ($all, $regex, $nor)', () => { + const validRules: AclRule[] = [ + { + action, + subject, + conditions: { + // @ts-ignore + name: { $regex: '^John' }, + tags: { $all: ['admin', 'moderator'] }, + $nor: [{ status: 'banned' }], + }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + validRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).not.toThrow(); + }); + + it('should throw HttpException for $size operator', () => { + const invalidRules: AclRule[] = [ + { + action, + subject, + conditions: { + // @ts-ignore + tags: { $size: 3 }, + }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + invalidRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).toThrow(HttpException); + }); + + it('should throw HttpException for $elemMatch operator', () => { + const invalidRules: AclRule[] = [ + { + action, + subject, + conditions: { + // @ts-ignore + comments: { + $elemMatch: { approved: true }, + }, + }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + invalidRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).toThrow(HttpException); + }); + + it('should throw HttpException for $options operator', () => { + const invalidRules: AclRule[] = [ + { + action, + subject, + conditions: { + // @ts-ignore + name: { $regex: 'john', $options: 'i' }, + }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + invalidRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).toThrow(HttpException); + }); + + it('should throw HttpException for $where operator', () => { + const invalidRules: AclRule[] = [ + { + action, + subject, + conditions: { + $where: 'this.age > 18', + } as any, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + invalidRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).toThrow(HttpException); + }); + + it('should throw HttpException on first unsupported operator (fail-fast)', () => { + const invalidRules: AclRule[] = [ + { + action, + subject, + conditions: { + // @ts-ignore + tags: { $size: 3 }, + comments: { $elemMatch: { approved: true } }, + }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + invalidRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).toThrow(HttpException); + }); + + it('should throw HttpException for nested unsupported operators in $or/$and', () => { + const invalidRules: AclRule[] = [ + { + action, + subject, + conditions: { + // @ts-ignore + $or: [ + { authorId: 123 }, + { tags: { $size: 5 } }, + ], + }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + invalidRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).toThrow(HttpException); + }); + + it('should pass validation for empty rules array', () => { + const ability = new ExtendAbility( + materialize, + subject, + action, + [] as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).not.toThrow(); + }); + + it('should pass validation for rules without conditions', () => { + const rulesWithoutConditions: AclRule[] = [ + { + action, + subject, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithoutConditions as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).not.toThrow(); + }); + + it('should pass validation for rules with only fields', () => { + const rulesWithFields: AclRule[] = [ + { + action, + subject, + fields: ['id', 'name', 'email'], + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + rulesWithFields as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).not.toThrow(); + }); + + it('should throw HttpException with helpful message', () => { + const invalidRules: AclRule[] = [ + { + action, + subject, + // @ts-ignore + conditions: { tags: { $size: 3 } }, + }, + ]; + + const ability = new ExtendAbility( + materialize, + subject, + action, + invalidRules as any, + {}, + {} + ); + + expect(() => validateRulesForORM(ability)).toThrow(HttpException); + }); +}); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-rules-for-orm.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-rules-for-orm.ts new file mode 100644 index 00000000..6fefcafd --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/orm-proxy/validate-rules-for-orm.ts @@ -0,0 +1,37 @@ +import { ExtendAbility } from '../../factories'; +import { handleAclQueryError } from './handle-acl-query-error'; + +/** + * Unsupported operators in MikroORM that exist in CASL + */ +const UNSUPPORTED_OPERATORS = ['$size', '$elemMatch', '$options', '$where']; + +/** + * Validates that ACL rules don't contain operators unsupported by MikroORM + * + * Uses fast string search to check for unsupported operators: + * - $size: array size check + * - $elemMatch: array element matching + * - $options: regex options + * - $where: JavaScript function execution + * + * Throws on first found unsupported operator (fail-fast approach) + * + * @param ability - ExtendAbility instance to validate + * @throws Error if any unsupported operator is found + */ +export function validateRulesForORM(ability: ExtendAbility): void { + // Fast check: convert to JSON and search for operator keys + const rulesJson = JSON.stringify(ability.rules); + + for (const operator of UNSUPPORTED_OPERATORS) { + // Search for operator as JSON key: "$operator" + if (rulesJson.includes(`"${operator}"`)) { + const error = new Error( + `Unsupported operator: ${operator}. ` + + `Supported operators: $eq, $ne, $lt, $lte, $gt, $gte, $in, $nin, $and, $or, $not, $exists, $all, $regex, $nor. ` + ); + throw handleAclQueryError(error, ability.subject, 'validateRulesForMikroORM'); + } + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/index.ts new file mode 100644 index 00000000..094657de --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/index.ts @@ -0,0 +1,55 @@ +import { Type, UseGuards } from '@nestjs/common'; +import { entityForClass, JsonBaseController, OrmService } from '@klerick/json-api-nestjs'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { ModuleRef } from '@nestjs/core'; +import { + ACL_CONTROLLER_METADATA, + MODULE_REF_PROPS, + ORIGINAL_ORM_SERVICE, +} from '../constants'; +import { AclControllerMetadata } from '../types'; +import { AclGuard } from '../guards'; +import { loggerWrapper } from './logger-init'; +import { wrapperJsonMethodController } from './wrapper-json-method-controller'; + + +export type WrapperJsonApiController = JsonBaseController & { + [MODULE_REF_PROPS]: ModuleRef; + [ORIGINAL_ORM_SERVICE]: OrmService; +}; + +export function wrapperJsonApiController(controllerClass: Type) { + + const entity = entityForClass(controllerClass); + if (!entity) return; + + const existingMetadata = Reflect.getMetadata( + ACL_CONTROLLER_METADATA, + controllerClass + ) as AclControllerMetadata | undefined; + if (!existingMetadata) { + const metadata: AclControllerMetadata = { + subject: entity, + methods: {}, + enabled: true, + }; + Reflect.defineMetadata(ACL_CONTROLLER_METADATA, metadata, controllerClass); + } + + const existingGuard = + Reflect.getMetadata(GUARDS_METADATA, controllerClass) || []; + + const hasPermissionInterceptor = existingGuard.some( + (guard: any) => guard === AclGuard || guard?.metatype === AclGuard + ); + + if (!hasPermissionInterceptor) { + UseGuards(AclGuard)(controllerClass); + } + + wrapperJsonMethodController(controllerClass) + + loggerWrapper.debug( + `Add ACL to "${controllerClass.name}" has been added` + ); +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/logger-init.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/logger-init.ts new file mode 100644 index 00000000..e128d37e --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/logger-init.ts @@ -0,0 +1,7 @@ +import { Logger } from '@nestjs/common'; + +if (process.env['USE_ATTACH_BUFFER']){ + Logger.attachBuffer() +} + +export const loggerWrapper = new Logger('ACL init'); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.ts new file mode 100644 index 00000000..853425c9 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/get-proxy-orm.ts @@ -0,0 +1,63 @@ +import { OrmService } from '@klerick/json-api-nestjs'; +import { ModuleRef } from '@nestjs/core'; + +import { + getAllProxy, + getOneProxy, + deleteOneProxy, + patchOneProxy, + postOneProxy, + getRelationshipProxy, + deleteRelationshipProxy, + patchRelationshipProxy, + postRelationshipProxy +} from './method-proxy'; +import type { AclControllerMetadata } from '../../types'; + +export function getProxyOrm( + ormService: OrmService, + moduleRef: ModuleRef, + metadata?: AclControllerMetadata +) { + return new Proxy(ormService, { + get(target, prop: keyof OrmService) { + // Fast path: if method is explicitly disabled, return original method + // This avoids proxy overhead when ACL is turned off for specific methods + if (metadata?.methods?.[prop as string] === false) { + return target[prop].bind(target); + } + + switch (prop) { + case 'getAll': + return getAllProxy(moduleRef).bind(target); + case 'getOne': + return getOneProxy(moduleRef).bind(target); + case 'patchOne': + return patchOneProxy(moduleRef).bind(target); + case 'postOne': + return postOneProxy(moduleRef).bind(target); + case 'deleteOne': + return deleteOneProxy(moduleRef).bind(target); + case 'getRelationship': + return getRelationshipProxy(moduleRef).bind(target); + case 'postRelationship': + return postRelationshipProxy(moduleRef).bind(target); + case 'patchRelationship': + return patchRelationshipProxy(moduleRef).bind(target); + case 'deleteRelationship': + return deleteRelationshipProxy(moduleRef).bind(target); + default: + return target[prop].bind(target); + } + }, + has(target, prop) { + return Reflect.has(target, prop); + }, + ownKeys(target) { + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, prop) { + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + }); +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/index.ts new file mode 100644 index 00000000..d76deb66 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/index.ts @@ -0,0 +1,24 @@ +import { Inject, Type } from '@nestjs/common'; +import { + JsonBaseController, +} from '@klerick/json-api-nestjs'; +import { ModuleRef } from '@nestjs/core'; +import { MODULE_REF_PROPS } from '../../constants'; +import { onModuleInit } from './on-module-init'; + +export function wrapperJsonMethodController( + controllerClass: Type> +) { + + if (!controllerClass.prototype['onModuleInit']) { + controllerClass.prototype['onModuleInit'] = onModuleInit; + } else { + const saveInit = controllerClass.prototype['onModuleInit']; + controllerClass.prototype['onModuleInit'] = function (this: any) { + saveInit.call(this); + onModuleInit.call(this); + } + } + + Inject(ModuleRef)(controllerClass.prototype, MODULE_REF_PROPS); +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.ts new file mode 100644 index 00000000..3bf7fafc --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-one-proxy.ts @@ -0,0 +1,88 @@ +import { ModuleRef } from '@nestjs/core'; +import { OrmService, QueryOne } from '@klerick/json-api-nestjs'; +import { ExtendAbility } from '../../../factories'; +import { + handleAclQueryError, + prepareAclQuery, + validateNoCurrentInRules, +} from '../../../utils'; +import { ForbiddenException, Logger } from '@nestjs/common'; +import { subject } from '@casl/ability'; + +export function deleteOneProxy( + moduleRef: ModuleRef +) { + return async function deleteOneBind( + this: OrmService, + id: Parameters['deleteOne']>[0] + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + + const aclPrepared = prepareAclQuery>( + extendAbility, + { + include: [], + fields: null, + }, + false + ); + if (!aclPrepared) { + return this.deleteOne(id); + } + + validateNoCurrentInRules(extendAbility, 'deleteOneProxy'); + + const { + mergedQuery, + } = aclPrepared; + + // Fetch entity with ACL conditions - handle errors from invalid ACL rules + let result: Awaited['getOne']>>; + + try { + result = await this.getOne( + id, + { + fields: null, + include: mergedQuery.include, + }, + false, + undefined + ); + } catch (error) { + throw handleAclQueryError(error, extendAbility.subject, 'deleteOneProxy'); + } + + const resultItem = result as E; + extendAbility.updateWithInput(resultItem); + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, resultItem) + ) + ) { + Logger.debug( + `Access denied for (action: ${extendAbility.action}, subject: ${extendAbility.subject})`, + 'deleteOneProxy', + { + subject: resultItem, + rules: extendAbility.rules, + } + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow "${extendAbility.action}"`, + path: ['action'], + }, + ], + { + description: `Access denied for ${extendAbility.action} on ${extendAbility.subject}`, + } + ); + } + + return this.deleteOne(id); + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.ts new file mode 100644 index 00000000..ce497bbd --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/delete-relationship-proxy.ts @@ -0,0 +1,109 @@ +import { OrmService, PostRelationshipData, QueryOne } from '@klerick/json-api-nestjs'; +import { ModuleRef } from '@nestjs/core'; +import { RelationKeys } from '@klerick/json-api-nestjs-shared'; +import { ExtendAbility } from '../../../factories'; +import { + handleAclQueryError, + prepareAclQuery, + validateNoCurrentInRules, +} from '../../../utils'; +import { subject } from '@casl/ability'; +import { ForbiddenException, Logger } from '@nestjs/common'; + +export function deleteRelationshipProxy( + moduleRef: ModuleRef +) { + return async function deleteRelationshipBind>( + this: OrmService, + id: IdKey, + rel: Rel, + input: PostRelationshipData + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + + const aclPrepared = prepareAclQuery>( + extendAbility, + { + include: [rel as any], + fields: null, + }, + false + ); + if (!aclPrepared) { + return this.deleteRelationship(id, rel, input); + } + + validateNoCurrentInRules(extendAbility, 'deleteRelationshipProxy'); + + const { mergedQuery } = aclPrepared; + + let result: Awaited['getOne']>>; + + try { + result = await this.getOne( + id, + { + fields: null, + include: mergedQuery.include, + }, + false, + undefined + ); + } catch (error) { + throw handleAclQueryError( + error, + extendAbility.subject, + 'deleteRelationshipProxy' + ); + } + + const resultItem = result as E; + + // Filter relationship to only items being deleted from input.data + const idsToDelete = new Set( + Array.isArray(input) + ? input.map((item) => item.id) + : [input.id] + ); + + // For to-many relationships, filter to only items being deleted + // For to-one relationships, keep as is + if (Array.isArray((resultItem as any)[rel])) { + (resultItem as any)[rel] = (resultItem as any)[rel].filter((item: any) => + idsToDelete.has(item.id) + ); + } + + extendAbility.updateWithInput(resultItem); + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, resultItem), + rel.toString() + ) + ) { + Logger.debug( + `Access denied for (action: ${extendAbility.action}, subject: ${extendAbility.subject}), field ${rel.toString()}`, + 'deleteRelationshipProxy', + { + subject: resultItem, + rules: extendAbility.rules, + } + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow "${extendAbility.action}"`, + path: ['action'], + }, + ], + { + description: `Access denied for ${extendAbility.action} on ${extendAbility.subject}`, + } + ); + } + + return this.deleteRelationship(id, rel, input); + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.ts new file mode 100644 index 00000000..c6376699 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-all-proxy.ts @@ -0,0 +1,124 @@ +import { + JsonApiTransformerService, + OrmService, + Query, +} from '@klerick/json-api-nestjs'; +import { ModuleRef } from '@nestjs/core'; +import { ExtendAbility } from '../../../factories'; +import { + extractFieldsForCheck, + handleAclQueryError, + prepareAclQuery, + getCurrentEntityAndParamMap, + processItemFieldRestrictions, + validateNoCurrentInRules, +} from '../../../utils'; + +export function getAllProxy( + moduleRef: ModuleRef +) { + return async function getAllBind( + this: OrmService, + query: Parameters['getAll']>[0] + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + + const aclPrepared = prepareAclQuery>( + extendAbility, + query + ); + + if (!aclPrepared) { + return this.getAll(query); + } + + validateNoCurrentInRules(extendAbility, 'getAllProxy'); + + const { transformToJsonApi, aclQueryData, mergedQuery } = aclPrepared; + + + // Fetch entity with ACL conditions - handle errors from invalid ACL rules + let result: Awaited['getAll']>>; + try { + result = await this.getAll( + mergedQuery, + transformToJsonApi, + aclQueryData?.rulesForQuery + ); + } catch (error) { + throw handleAclQueryError(error, extendAbility.subject, 'getAllProxy'); + } + + // If already transformed by ORM, return as is + if (transformToJsonApi) { + return result; + } + + // Manual transformation with field filtering + const { totalItems, items } = result as { totalItems: number; items: E[] }; + const { page } = query; + + // Build meta + const meta = { + totalItems, + pageNumber: page.number, + pageSize: page.size, + }; + + // If empty, return immediately + if (totalItems === 0 || items.length === 0) { + return { meta, data: [] }; + } + + const fieldsForCheck = extractFieldsForCheck>( + moduleRef, + items[0], + query, + aclQueryData + ); + + const { entityParamMap } = getCurrentEntityAndParamMap(moduleRef); + + // Field filtering logic + const fieldRestrictions: Array<{ id: IdKey; fields: string[] }> = []; + + for (const item of items) { + const restrictedFields = processItemFieldRestrictions>( + item, + fieldsForCheck, + extendAbility, + query, + aclQueryData + ); + + if (restrictedFields.length > 0) { + fieldRestrictions.push({ + [entityParamMap.primaryColumnName]: Reflect.get( + item, + entityParamMap.primaryColumnName + ) as IdKey, + fields: restrictedFields, + }); + } + } + + // Transform data using JsonApiTransformerService + const jsonApiTransformerService = moduleRef.get< + JsonApiTransformerService + >(JsonApiTransformerService); + + const { data, included } = jsonApiTransformerService.transformData( + items, + query + ); + + return { + meta: { + ...meta, + ...(fieldRestrictions.length > 0 && { fieldRestrictions }), + }, + data, + ...(included ? { included } : {}), + }; + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.ts new file mode 100644 index 00000000..638ecc91 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-one-proxy.ts @@ -0,0 +1,107 @@ +import { ModuleRef } from '@nestjs/core'; +import { + JsonApiTransformerService, + OrmService, + QueryOne, +} from '@klerick/json-api-nestjs'; +import { ExtendAbility } from '../../../factories'; +import { + extractFieldsForCheck, + handleAclQueryError, + prepareAclQuery, + getCurrentEntityAndParamMap, + processItemFieldRestrictions, + validateNoCurrentInRules, +} from '../../../utils'; + +export function getOneProxy( + moduleRef: ModuleRef +) { + return async function getOneBind( + this: OrmService, + id: Parameters['getOne']>[0], + query: Parameters['getOne']>[1] + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + + const aclPrepared = prepareAclQuery>( + extendAbility, + query + ); + + if (!aclPrepared) { + return this.getOne(id, query); + } + + validateNoCurrentInRules(extendAbility, 'getOneProxy'); + + const { transformToJsonApi, aclQueryData, mergedQuery } = aclPrepared; + + // Fetch entity with ACL conditions - handle errors from invalid ACL rules + let result: Awaited['getOne']>>; + + try { + result = await this.getOne( + id, + mergedQuery, + transformToJsonApi, + aclQueryData?.rulesForQuery + ); + } catch (error) { + throw handleAclQueryError(error, extendAbility.subject, 'getOneProxy'); + } + + // If already transformed by ORM, return as is + if (transformToJsonApi) { + return result; + } + + const resultItem = result as E; + + const fieldsForCheck = extractFieldsForCheck>( + moduleRef, + resultItem, + query, + aclQueryData + ); + + const { entityParamMap } = getCurrentEntityAndParamMap(moduleRef); + + const fieldRestrictions: Array<{ id: IdKey; fields: string[] }> = []; + + const restrictedFields = processItemFieldRestrictions>( + resultItem, + fieldsForCheck, + extendAbility, + query, + aclQueryData + ); + + if (restrictedFields.length > 0) { + fieldRestrictions.push({ + [entityParamMap.primaryColumnName]: Reflect.get( + resultItem, + entityParamMap.primaryColumnName + ) as IdKey, + fields: restrictedFields, + }); + } + + const jsonApiTransformerService = moduleRef.get< + JsonApiTransformerService + >(JsonApiTransformerService); + + const { data, included } = jsonApiTransformerService.transformData( + resultItem, + query + ); + + return { + meta: { + ...(fieldRestrictions.length > 0 && { fieldRestrictions }), + }, + data, + ...(included ? { included } : {}), + }; + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.ts new file mode 100644 index 00000000..30e65473 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/get-relationship-proxy.ts @@ -0,0 +1,92 @@ +import { OrmService, QueryOne } from '@klerick/json-api-nestjs'; +import { ModuleRef } from '@nestjs/core'; +import { RelationKeys } from '@klerick/json-api-nestjs-shared'; +import { ExtendAbility } from '../../../factories'; +import { + handleAclQueryError, + prepareAclQuery, + validateNoCurrentInRules, +} from '../../../utils'; +import { subject } from '@casl/ability'; +import { ForbiddenException, Logger } from '@nestjs/common'; + +export function getRelationshipProxy( + moduleRef: ModuleRef +) { + return async function getOneBind>( + this: OrmService, + id: IdKey, + rel: Rel + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + + const aclPrepared = prepareAclQuery>( + extendAbility, + { + include: [rel as any], + fields: null, + }, + false + ); + if (!aclPrepared) { + return this.getRelationship(id, rel); + } + + validateNoCurrentInRules(extendAbility, 'getRelationshipProxy'); + + const { mergedQuery } = aclPrepared; + + let result: Awaited['getOne']>>; + + try { + result = await this.getOne( + id, + { + fields: null, + include: mergedQuery.include, + }, + false, + undefined + ); + } catch (error) { + throw handleAclQueryError( + error, + extendAbility.subject, + 'getRelationshipProxy' + ); + } + + const resultItem = result as E; + extendAbility.updateWithInput(resultItem); + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, resultItem), + rel.toString() + ) + ) { + Logger.debug( + `Access denied for (action: ${extendAbility.action}, subject: ${extendAbility.subject}), field ${rel.toString()}`, + 'getRelationshipProxy', + { + subject: resultItem, + rules: extendAbility.rules, + } + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow "${extendAbility.action}"`, + path: ['action'], + }, + ], + { + description: `Access denied for ${extendAbility.action} on ${extendAbility.subject}`, + } + ); + } + + return this.getRelationship(id, rel); + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.ts new file mode 100644 index 00000000..d0abb4b5 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/index.ts @@ -0,0 +1,9 @@ +export * from './get-all-proxy' +export * from './get-one-proxy' +export * from './delete-one-proxy' +export * from './patch-one-proxy' +export * from './post-one-proxy' +export * from './get-relationship-proxy' +export * from './delete-relationship-proxy' +export * from './post-relationship-proxy' +export * from './patch-relationship-proxy' diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.ts new file mode 100644 index 00000000..c5476b39 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-one-proxy.ts @@ -0,0 +1,199 @@ +import { ModuleRef } from '@nestjs/core'; +import { OrmService, QueryOne } from '@klerick/json-api-nestjs'; +import { ExtendAbility } from '../../../factories'; +import { + handleAclQueryError, + prepareAclQuery, + getCurrentEntityAndParamMap, + ExtractFieldPaths, +} from '../../../utils'; +import { subject } from '@casl/ability'; +import { ForbiddenException, Logger } from '@nestjs/common'; + +export function patchOneProxy( + moduleRef: ModuleRef +) { + return async function patchOneBind( + this: OrmService, + id: Parameters['patchOne']>[0], + inputData: Parameters['patchOne']>[1] + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + const aclPrepared = prepareAclQuery>( + extendAbility, + { + include: [], + fields: null, + } + ); + if (!aclPrepared) { + return this.patchOne(id, inputData); + } + const { mergedQuery } = aclPrepared; + let result: Awaited['getOne']>>; + + try { + result = await this.getOne( + id, + { + fields: null, + include: mergedQuery.include, + }, + false, + undefined + ); + } catch (error) { + throw handleAclQueryError(error, extendAbility.subject, 'patchOneProxy'); + } + const resultItem = result as E; + + const { relationships, attributes } = inputData; + + const relationshipsToChange = Object.keys(relationships || {}); + + let loadedRelations: Partial> = {}; + + if (relationships) { + try { + loadedRelations = await this.loadRelations(relationships); + } catch (error) { + throw handleAclQueryError( + error, + extendAbility.subject, + 'patchOneProxy' + ); + } + } + + const { currentEntity: entityClass, entityParamMapService } = + getCurrentEntityAndParamMap(moduleRef); + + const extractor = ExtractFieldPaths.getInstance(entityParamMapService); + const currentEntityPropsOnly = extractor.props(resultItem, entityClass); + + const mergedEntity = { + ...currentEntityPropsOnly, + ...(attributes || {}), + ...loadedRelations, + } as E; + + // Detect changed attributes by comparing old (DB) vs new (request) values + // Coverage: ~90% of real-world use cases + // + // Comparison strategy: + // - Primitives (string, number, boolean, null): strict equality (===) + // - Objects/Arrays: JSON.stringify comparison + // + // Known edge cases (acceptable tradeoffs): + // 1. JSONB fields with different key order may trigger false positives: + // { a: 1, b: 2 } !== { b: 2, a: 1 } (content identical, but detected as different) + // 2. Circular references: Not expected in JSON API requests (would fail JSON.parse) + // 3. Date objects: try to use toISOString() for comparison + // + // If you encounter issues, please create a GitHub issue with your use case. + const changedAttributes: string[] = []; + if (attributes) { + for (const attrKey of Object.keys(attributes)) { + let currentValue = (currentEntityPropsOnly as Record)[ + attrKey + ]; + let newValue = (attributes as Record)[attrKey]; + newValue = + newValue !== null && typeof newValue === 'object' + ? newValue instanceof Date ? newValue.toISOString() : JSON.stringify(newValue) + : newValue; + currentValue = + currentValue !== null && typeof currentValue === 'object' + ? currentValue instanceof Date ? currentValue.toISOString() : JSON.stringify(currentValue) + : currentValue; + + if (currentValue !== newValue) { + changedAttributes.push(attrKey); + } + } + } + + const changedFields = [...changedAttributes, ...relationshipsToChange]; + + // Entity for check contains: + // - Root level: NEW values (merged entity after applying changes) + // - __current: OLD values (entity as loaded from DB) + // This enables rules to compare old vs new values, e.g.: + // - Allow removing only self: { '__current.coAuthorIds': { $in: [@input.userId] }, 'coAuthorIds': { $nin: [@input.userId] } } + const entityForCheck = { + ...mergedEntity, + __current: resultItem, + } as E; + + extendAbility.updateWithInput(entityForCheck); + + // Entity-level check + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, entityForCheck) + ) + ) { + Logger.debug( + `Access denied for (action: ${extendAbility.action}, subject: ${extendAbility.subject})`, + 'patchOneProxy', + { + subject: entityForCheck, + rules: extendAbility.rules, + } + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow "${extendAbility.action}"`, + path: ['action'], + }, + ], + { + description: `Access denied for ${extendAbility.action} on ${extendAbility.subject}`, + } + ); + } + + // Field-level checks for changed fields + for (const field of changedFields) { + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, entityForCheck), + field + ) + ) { + Logger.debug( + `Field-level access denied for field '${field}'`, + 'patchOneProxy', + { + field, + currentValue: ( + (entityForCheck as any).__current as Record + )[field], + newValue: (entityForCheck as Record)[field], + subject: entityForCheck, + rules: extendAbility.rules, + } + ); + + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow to modify field "${field}"`, + path: ['data', 'attributes', field], + }, + ], + { + description: `Field-level access denied for ${field}`, + } + ); + } + } + + return this.patchOne(id, inputData); + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.ts new file mode 100644 index 00000000..b4171cfc --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/patch-relationship-proxy.ts @@ -0,0 +1,114 @@ +import { OrmService, PatchData, PatchRelationshipData, QueryOne } from '@klerick/json-api-nestjs'; +import { ModuleRef } from '@nestjs/core'; +import { RelationKeys } from '@klerick/json-api-nestjs-shared'; +import { ExtendAbility } from '../../../factories'; +import { handleAclQueryError, prepareAclQuery } from '../../../utils'; +import { subject } from '@casl/ability'; +import { ForbiddenException, Logger } from '@nestjs/common'; + +export function patchRelationshipProxy( + moduleRef: ModuleRef +) { + return async function patchRelationshipBind>( + this: OrmService, + id: IdKey, + rel: Rel, + input: PatchRelationshipData + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + + const aclPrepared = prepareAclQuery>( + extendAbility, + { + include: [rel as any], + fields: null, + }, + false + ); + if (!aclPrepared) { + return this.patchRelationship(id, rel, input); + } + + const { mergedQuery } = aclPrepared; + + let result: Awaited['getOne']>>; + + try { + result = await this.getOne( + id, + { + fields: null, + include: mergedQuery.include, + }, + false, + undefined + ); + } catch (error) { + throw handleAclQueryError( + error, + extendAbility.subject, + 'patchRelationshipProxy' + ); + } + + const oldResult = result as E; + + // Transform input to relationships format for loadRelations + const relationshipsData = { + [rel]: input, + } as PatchData['relationships']; + + let loadedRelations: Partial>; + + try { + loadedRelations = await this.loadRelations(relationshipsData); + } catch (error) { + throw handleAclQueryError( + error, + extendAbility.subject, + 'patchRelationshipProxy' + ); + } + + // Entity for check contains: + // - Root level: old entity + NEW relationships (replacing old ones) + // - __current: old entity with OLD relationships (as loaded from DB) + const entityToCheck = { + ...oldResult, + [rel]: loadedRelations[rel.toString()], // NEW relationships (overwrite old) + __current: oldResult, // OLD entity with OLD relationships + } as E; + + extendAbility.updateWithInput(entityToCheck); + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, entityToCheck), + rel.toString() + ) + ) { + Logger.debug( + `Access denied for (action: ${extendAbility.action}, subject: ${extendAbility.subject}), field ${rel.toString()}`, + 'patchRelationshipProxy', + { + subject: entityToCheck, + rules: extendAbility.rules, + } + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow "${extendAbility.action}"`, + path: ['action'], + }, + ], + { + description: `Access denied for ${extendAbility.action} on ${extendAbility.subject}`, + } + ); + } + + return this.patchRelationship(id, rel, input); + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.ts new file mode 100644 index 00000000..984b0986 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-one-proxy.ts @@ -0,0 +1,117 @@ +import { ModuleRef } from '@nestjs/core'; +import { OrmService } from '@klerick/json-api-nestjs'; +import { ExtendAbility } from '../../../factories'; +import { handleAclQueryError, validateNoCurrentInRules } from '../../../utils'; +import { subject } from '@casl/ability'; +import { ForbiddenException, Logger } from '@nestjs/common'; + +export function postOneProxy( + moduleRef: ModuleRef +) { + return async function postOneBind( + this: OrmService, + inputData: Parameters['postOne']>[0] + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + if ( + !extendAbility || + extendAbility.rules.length === 0 || + (!extendAbility.hasConditions && !extendAbility.hasFields) + ) { + return this.postOne(inputData); + } + + validateNoCurrentInRules(extendAbility, 'postOneProxy'); + + const { relationships, attributes } = inputData; + + let loadedRelations: Partial> = {}; + + if (relationships) { + try { + loadedRelations = await this.loadRelations(relationships); + } catch (error) { + throw handleAclQueryError( + error, + extendAbility.subject, + 'postOneProxy' + ); + } + } + + const resultEntity = { + ...(attributes || {}), + ...loadedRelations, + } as E; + + const changedAttributes: string[] = [ + ...Object.keys(attributes || {}), + ...Object.keys(loadedRelations), + ]; + + extendAbility.updateWithInput(resultEntity); + + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, resultEntity) + ) + ) { + Logger.debug( + `Access denied for (action: ${extendAbility.action}, subject: ${extendAbility.subject})`, + 'postOneProxy', + { + subject: resultEntity, + rules: extendAbility.rules, + } + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow "${extendAbility.action}"`, + path: ['action'], + }, + ], + { + description: `Access denied for ${extendAbility.action} on ${extendAbility.subject}`, + } + ); + } + + for (const field of changedAttributes) { + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, resultEntity), + field + ) + ) { + Logger.debug( + `Field-level access denied for field '${field}'`, + 'postOneProxy', + { + field, + value: (resultEntity as Record)[field], + subject: resultEntity, + rules: extendAbility.rules, + } + ); + + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow to set field "${field}"`, + path: ['data', 'attributes', field], + }, + ], + { + description: `Field-level access denied for ${field}`, + } + ); + } + } + return this.postOne(inputData); + }; +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.ts new file mode 100644 index 00000000..ed39465d --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/method-proxy/post-relationship-proxy.ts @@ -0,0 +1,117 @@ +import { OrmService, PostData, PostRelationshipData, QueryOne } from '@klerick/json-api-nestjs'; +import { ModuleRef } from '@nestjs/core'; +import { RelationKeys } from '@klerick/json-api-nestjs-shared'; +import { ExtendAbility } from '../../../factories'; +import { + handleAclQueryError, + prepareAclQuery, + validateNoCurrentInRules, +} from '../../../utils'; +import { subject } from '@casl/ability'; +import { ForbiddenException, Logger } from '@nestjs/common'; + +export function postRelationshipProxy( + moduleRef: ModuleRef +) { + return async function postRelationshipBind>( + this: OrmService, + id: IdKey, + rel: Rel, + input: PostRelationshipData + ) { + const extendAbility = moduleRef.get(ExtendAbility, { strict: false }); + + const aclPrepared = prepareAclQuery>( + extendAbility, + { + include: [], + fields: null, + }, + false + ); + if (!aclPrepared) { + return this.postRelationship(id, rel, input); + } + + validateNoCurrentInRules(extendAbility, 'postRelationshipProxy'); + + const { mergedQuery } = aclPrepared; + + let result: Awaited['getOne']>>; + + try { + result = await this.getOne( + id, + { + fields: null, + include: mergedQuery.include, + }, + false, + undefined + ); + } catch (error) { + throw handleAclQueryError( + error, + extendAbility.subject, + 'postRelationshipProxy' + ); + } + + const resultItem = result as E; + + // Transform input to relationships format for loadRelations + const relationshipsData = { + [rel]: input, + } as PostData['relationships']; + + let loadedRelations: Partial>; + + try { + loadedRelations = await this.loadRelations(relationshipsData); + } catch (error) { + throw handleAclQueryError( + error, + extendAbility.subject, + 'postRelationshipProxy' + ); + } + + // Merge entity with relations being added + const entityToCheck = { + ...resultItem, + ...loadedRelations, + } as E; + + extendAbility.updateWithInput(entityToCheck); + if ( + !extendAbility.can( + extendAbility.action, + subject(extendAbility.subject, entityToCheck), + rel.toString() + ) + ) { + Logger.debug( + `Access denied for (action: ${extendAbility.action}, subject: ${extendAbility.subject}), field ${rel.toString()}`, + 'postRelationshipProxy', + { + subject: entityToCheck, + rules: extendAbility.rules, + } + ); + throw new ForbiddenException( + [ + { + code: 'forbidden', + message: `not allow "${extendAbility.action}"`, + path: ['action'], + }, + ], + { + description: `Access denied for ${extendAbility.action} on ${extendAbility.subject}`, + } + ); + } + + return this.postRelationship(id, rel, input); + }; +} \ No newline at end of file diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/on-module-init.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/on-module-init.ts new file mode 100644 index 00000000..06ce764a --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/wrappers/wrapper-json-method-controller/on-module-init.ts @@ -0,0 +1,33 @@ +import { OrmService } from '@klerick/json-api-nestjs'; +import { MODULE_REF_PROPS, ORIGINAL_ORM_SERVICE, ACL_CONTROLLER_METADATA } from '../../constants'; +import { getProxyOrm } from './get-proxy-orm'; +import type { AclControllerMetadata } from '../../types'; + +import {type WrapperJsonApiController} from '../' + +export function onModuleInit( + this: WrapperJsonApiController +) { + const serviceSymbolsProps = Object.getOwnPropertySymbols(this).find( + (sym) => sym.description === 'ORM_SERVICE_PROPS' + ); + + if (!serviceSymbolsProps) throw new Error('Not found ORM_SERVICE_PROPS'); + const ormService: OrmService = Reflect.get( + this, + serviceSymbolsProps + ); + + // Get ACL metadata to check which methods are disabled + const metadata: AclControllerMetadata | undefined = Reflect.getMetadata( + ACL_CONTROLLER_METADATA, + this.constructor + ); + + Reflect.set( + this, + serviceSymbolsProps, + getProxyOrm(ormService, this[MODULE_REF_PROPS], metadata) + ); + Reflect.set(this, ORIGINAL_ORM_SERVICE, ormService); +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/tsconfig.json b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.json new file mode 100644 index 00000000..0dc79caa --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/tsconfig.lib.json b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.lib.json new file mode 100644 index 00000000..d5c3fa0e --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": [ + "node" + ], + "target": "es2023", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + }, + "include": [ + "src/**/*.ts", + ], + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.test-d.ts", + ] +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/tsconfig.spec.json b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.spec.json new file mode 100644 index 00000000..0f260c97 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.spec.json @@ -0,0 +1,33 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "ES2022", + "moduleResolution": "node10", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ], + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts", + ] +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/vite.config.mts b/libs/acl-permissions/nestjs-acl-permissions/vite.config.mts new file mode 100644 index 00000000..9434eae5 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/vite.config.mts @@ -0,0 +1,45 @@ +/// +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +import swc from 'unplugin-swc'; +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/nestjs-acl-permissions', + plugins: [ + nxViteTsPaths(), + swc.vite({ + module: { type: 'es6' }, + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + decorators: true, + }, + transform: { + decoratorMetadata: true, + legacyDecorator: true, + }, + keepClassNames: true, + externalHelpers: true, + loose: true, + }, + }), + ], + test: { + name: 'nestjs-acl-permissions', + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: [], + reporters: ['default'], + coverage: { + enabled: true, + reporter: ['json-summary'], + reportsDirectory: '../../../coverage/nestjs-acl-permissions', + provider: 'v8' as const, + }, + }, +})); diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/factory/index.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/factory/index.ts index d7d1b63a..67b65556 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/factory/index.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/factory/index.ts @@ -50,9 +50,7 @@ export function CurrentMicroOrmProvider( ): FactoryProvider { return { provide: CURRENT_DATA_SOURCE_TOKEN, - useFactory: (mikroORM: MikroORM) => { - return mikroORM; - }, + useFactory: (mikroORM: MikroORM) => mikroORM, inject: [connectionName ? getMikroORMToken(connectionName) : MikroORM], }; } @@ -132,9 +130,10 @@ export function EntityPropsMap(entities: EntityClass[]) { export function RunInTransactionFactory(): FactoryProvider { return { provide: RUN_IN_TRANSACTION_FUNCTION, - inject: [], - useFactory() { - return async (callback) => callback(); + inject: [CURRENT_ENTITY_MANAGER_TOKEN], + useFactory(entityManager: EntityManager) { + return (callback) => + entityManager.transactional(() => callback()); }, }; } diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/index.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/index.ts index 985a6a5c..4b7f3b61 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/index.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/index.ts @@ -26,12 +26,12 @@ import { OrmServiceFactory, EntityPropsMap, } from '../factory'; -import { MicroOrmUtilService } from '../service/micro-orm-util.service'; +import { MicroOrmUtilService } from '../service'; export * from './entities'; export * from './utils'; -import { sharedConnect, initMikroOrm, pullAllData } from './utils'; +import { initMikroOrm, pullAllData } from './utils'; import { DEFAULT_ARRAY_TYPE } from '../constants'; export const entities = [Users, UserGroups, Roles, Comments, Addresses, Notes]; @@ -39,10 +39,7 @@ export const entities = [Users, UserGroups, Roles, Comments, Addresses, Notes]; export function mockDbPgLiteTestModule(dbName = `test_db_${Date.now()}`) { const mikroORM = { provide: MikroORM, - useFactory: async function () { - const knexInst = await sharedConnect(); - return initMikroOrm(knexInst, dbName); - }, + useFactory: () => initMikroOrm(dbName), }; return { module: MikroOrmModule, diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/utils/init-db.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/utils/init-db.ts index abf9d327..46f238f0 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/utils/init-db.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/utils/init-db.ts @@ -3,9 +3,6 @@ import { MikroORM } from '@mikro-orm/core'; import { PostgreSqlDriver } from '@mikro-orm/postgresql'; import { SqlHighlighter } from '@mikro-orm/sql-highlighter'; -import Knex from 'knex'; -import ClientPgLite from 'knex-pglite'; - import { Addresses, Comments, @@ -14,63 +11,37 @@ import { UserGroups, Users, } from '../entities'; - -let knexInst: TypeKnex; - -export async function sharedConnect(): Promise { - if (knexInst) { - return knexInst; - } - - const pgLite = await Promise.all([ - import('@electric-sql/pglite'), - // @ts-ignore - import('@electric-sql/pglite/contrib/uuid_ossp'), - ]).then( - ([{ PGlite }, { uuid_ossp }]) => - new PGlite({ - extensions: { uuid_ossp }, - }) - ); - - knexInst = Knex({ - client: ClientPgLite, - dialect: 'postgres', - // @ts-ignore - connection: { pglite: pgLite }, +import { PGlite } from '@electric-sql/pglite'; +// @ts-ignore +import { PGliteDriver, PGliteConnectionConfig } from 'mikro-orm-pglite'; +// @ts-ignore +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp'; + +export async function initMikroOrm(testDbName: string) { + const pgLite = new PGlite({ + extensions: { uuid_ossp }, }); - return knexInst; -} - -export async function initMikroOrm(knex: TypeKnex, testDbName: string) { - const result = await knex.raw( - `select 1 from pg_database where datname = '${testDbName}'` - ); - - if ((result['rows'] as []).length === 0) { - await knex.raw(`create database ??`, [testDbName]); - } - const orm = await MikroORM.init({ highlighter: new SqlHighlighter(), - driver: PostgreSqlDriver, + driver: PGliteDriver, dbName: testDbName, - driverOptions: knexInst, + driverOptions: { + connection: { + pglite: () => pgLite, + } satisfies PGliteConnectionConfig, + }, entities: [Users, UserGroups, Roles, Comments, Addresses, Notes], allowGlobalContext: true, schema: 'public', - debug: - process.env['DB_LOGGING'] !== '0' ? ['query', 'query-params'] : false, + debug: false + // process.env['DB_LOGGING'] !== '0' ? ['query', 'query-params'] : false, }); - if ((result['rows'] as []).length === 0) { - const sql = await orm.getSchemaGenerator().getCreateSchemaSQL(); - const statements = sql.split(';').filter((s) => s.trim().length > 0); // Разбиваем на отдельные команды - - for (const statement of statements) { - await orm.em.execute(statement); - } + const sql = await orm.getSchemaGenerator().getCreateSchemaSQL(); + const statements = sql.split(';').filter((s) => s.trim().length > 0); // Разбиваем на отдельные команды + for (const statement of statements) { + await orm.em.execute(statement); } return orm; diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/utils/pull-data.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/utils/pull-data.ts index 5ea47448..bdb6175b 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/utils/pull-data.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/mock-utils/utils/pull-data.ts @@ -47,7 +47,7 @@ export async function pullUser() { user.firstName = faker.person.firstName(); user.lastName = faker.person.lastName(); user.isActive = faker.datatype.boolean(); - user.login = faker.internet.userName({ + user.login = faker.internet.username({ lastName: user.lastName, firstName: user.firstName, }); diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-helper/index.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-helper/index.ts index 5edcefe3..0df864f7 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-helper/index.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-helper/index.ts @@ -1,4 +1,5 @@ import { EntityKey, EntityMetadata } from '@mikro-orm/core'; +import { Logger } from '@nestjs/common'; import { EntityParam, TypeField, @@ -7,6 +8,7 @@ import { import { MicroOrmParam } from '../type'; import { DEFAULT_ARRAY_TYPE } from '../constants'; + export const getRelation = ( entityMetadata: EntityMetadata ) => @@ -30,6 +32,7 @@ export const getPropsType = ( entityMetadata: EntityMetadata, config: PrepareParams['options']['arrayType'] = DEFAULT_ARRAY_TYPE ): EntityParam['propsType'] => { + const logger = new Logger('JSON-API:MkroORM: init'); const field = getProps(entityMetadata); const result = {} as any; @@ -56,6 +59,10 @@ export const getPropsType = ( case 'object': typeProps = TypeField.object; break; + case 'any': + logger.warn(`The field "${item}" in entity ${entityMetadata.name} has runtime type "any". Should be use object type.`); + typeProps = TypeField.object; + break; default: typeProps = TypeField.string; } diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/delete-relationship/delete-relationship.spec.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/delete-relationship/delete-relationship.spec.ts index 6901ce73..33579636 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/delete-relationship/delete-relationship.spec.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/delete-relationship/delete-relationship.spec.ts @@ -74,7 +74,7 @@ describe('delete-relationship', () => { firstName: firstName, lastName: lastName, isActive: faker.datatype.boolean(), - login: faker.internet.userName({ + login: faker.internet.username({ lastName: firstName, firstName: lastName, }), diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-all/get-all.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-all/get-all.ts index 4afa946b..1836f7d2 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-all/get-all.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-all/get-all.ts @@ -6,7 +6,8 @@ import { getQueryForCount, getSortObject } from './get-query-for-count'; export async function getAll( this: MicroOrmService, - query: Query + query: Query, + additionalQueryParams?: Record ): Promise<{ totalItems: number; items: E[]; @@ -18,6 +19,10 @@ export async function getAll( ReturnType> >(this, ...[query]); + if (additionalQueryParams) { + countSubQuery.andWhere(additionalQueryParams); + } + const skip = (page.number - 1) * page.size; await countSubQuery.applyFilters(); diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-one/get-one.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-one/get-one.ts index ec117a03..ed624792 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-one/get-one.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-one/get-one.ts @@ -6,7 +6,8 @@ import { MicroOrmService } from '../../service'; export async function getOne( this: MicroOrmService, id: number | string, - query: QueryOne + query: QueryOne, + additionalQueryParams?: Record ): Promise { const queryBuilder = this.microOrmUtilService.queryBuilder().where({ [this.microOrmUtilService.currentPrimaryColumn]: id, @@ -17,6 +18,10 @@ export async function getOne( query as any ); + if (additionalQueryParams) { + resultQueryBuilder.andWhere(additionalQueryParams); + } + await resultQueryBuilder.applyFilters(); const resultItem = await resultQueryBuilder.getSingleResult(); diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-relationship/get-relationship.spec.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-relationship/get-relationship.spec.ts index cca420a2..6e2d362f 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-relationship/get-relationship.spec.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/get-relationship/get-relationship.spec.ts @@ -78,7 +78,7 @@ describe('get-relationship', () => { firstName: firstName, lastName: lastName, isActive: faker.datatype.boolean(), - login: faker.internet.userName({ + login: faker.internet.username({ lastName: firstName, firstName: lastName, }), diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/patch-one/patch-one.spec.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/patch-one/patch-one.spec.ts index 58919686..79e858d6 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/patch-one/patch-one.spec.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/patch-one/patch-one.spec.ts @@ -86,7 +86,7 @@ describe('patch-one', () => { firstName: firstName, lastName: lastName, isActive: faker.datatype.boolean(), - login: faker.internet.userName({ + login: faker.internet.username({ lastName: firstName, firstName: lastName, }), diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/patch-relationship/patch-relationship.spec.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/patch-relationship/patch-relationship.spec.ts index b52a9beb..5288fb13 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/patch-relationship/patch-relationship.spec.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/patch-relationship/patch-relationship.spec.ts @@ -63,7 +63,7 @@ describe('patch-relationship', () => { firstName: firstName, lastName: lastName, isActive: faker.datatype.boolean(), - login: faker.internet.userName({ + login: faker.internet.username({ lastName: firstName, firstName: lastName, }), diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/post-one/post-one.spec.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/post-one/post-one.spec.ts index 38ab3b75..cfd4633e 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/post-one/post-one.spec.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/post-one/post-one.spec.ts @@ -82,7 +82,7 @@ describe('post-one', () => { firstName: firstName, lastName: lastName, isActive: faker.datatype.boolean(), - login: faker.internet.userName({ + login: faker.internet.username({ lastName: firstName, firstName: lastName, }), diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/post-relationship/post-relationship.spec.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/post-relationship/post-relationship.spec.ts index 103a8615..01239e0e 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/post-relationship/post-relationship.spec.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/orm-methods/post-relationship/post-relationship.spec.ts @@ -63,7 +63,7 @@ describe('post-relationshipa', () => { firstName: firstName, lastName: lastName, isActive: faker.datatype.boolean(), - login: faker.internet.userName({ + login: faker.internet.username({ lastName: firstName, firstName: lastName, }), diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/service/micro-orm-util.service.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/service/micro-orm-util.service.ts index 38f8dd62..c6c027ab 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/service/micro-orm-util.service.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/service/micro-orm-util.service.ts @@ -335,7 +335,8 @@ export class MicroOrmUtilService< private extractedResultOperand(operand: FilterOperand) { if ( operand === FilterOperand.like && - this.entityManager.getDriver().constructor.name === 'PostgreSqlDriver' + (this.entityManager.getDriver().constructor.name === 'PostgreSqlDriver' || + this.entityManager.getDriver().constructor.name === 'PGliteDriver') ) { return '$ilike'; } @@ -566,7 +567,7 @@ export class MicroOrmUtilService< }); } - private async *asyncIterateFindRelationships( + async *asyncIterateFindRelationships( relationships: NonNullable> ): AsyncGenerator> { for (const entries of ObjectTyped.entries(relationships)) { diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/service/microorm-service.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/service/microorm-service.ts index 37e1ee96..6b5c2ac6 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/service/microorm-service.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/service/microorm-service.ts @@ -1,8 +1,10 @@ import { Inject } from '@nestjs/common'; import { + ObjectTyped, QueryField, ResourceObject, ResourceObjectRelationships, + RelationKeys } from '@klerick/json-api-nestjs-shared'; import { JsonApiTransformerService, @@ -27,7 +29,6 @@ import { postRelationship, } from '../orm-methods'; import { MicroOrmUtilService } from './micro-orm-util.service'; -import { RelationKeys } from '@klerick/json-api-nestjs-shared'; export class MicroOrmService implements OrmService @@ -41,14 +42,29 @@ export class MicroOrmService async getAll( query: Query - ): Promise> { + ): Promise>; + async getAll( + query: Query, + transformData?: boolean, + additionalQueryParams?: Record + ): Promise>; + async getAll( + query: Query, + transformData = true, + additionalQueryParams?: Record + ): Promise< + ResourceObject | { totalItems: number; items: E[] } + > { const { page } = query; const { totalItems, items } = await getAll.call< MicroOrmService, Parameters>, ReturnType> - >(this, query); + >(this, query, additionalQueryParams); + if (!transformData) { + return { totalItems, items }; + } const { data, included } = this.jsonApiTransformerService.transformData( items, query @@ -70,12 +86,29 @@ export class MicroOrmService async getOne( id: number | string, query: QueryOne - ): Promise> { + ): Promise>; + async getOne( + id: number | string, + query: QueryOne, + transformData?: boolean, + additionalQueryParams?: Record + ): Promise | E>; + async getOne( + id: number | string, + query: QueryOne, + transformData = true, + additionalQueryParams?: Record + ): Promise | E> { const result = await getOne.call< MicroOrmService, Parameters>, ReturnType> - >(this, id, query); + >(this, id, query, additionalQueryParams); + + if (!transformData) { + return result; + } + const { data, included } = this.jsonApiTransformerService.transformData( result, query @@ -226,4 +259,23 @@ export class MicroOrmService data: this.jsonApiTransformerService.transformRel(result, rel), }; } + + async loadRelations( + relationships: PatchData['relationships'] | PostData['relationships'] + ): Promise<{ + [K in RelationKeys]: E[K]; + }> { + const result = {} as { [K in RelationKeys ]: E[K]; }; + + for await (const item of this.microOrmUtilService.asyncIterateFindRelationships( + relationships as any + )) { + const itemProps = ObjectTyped.entries(item).at(0); + if (!itemProps) continue; + const [nameProps, data] = itemProps; + Reflect.set(result, nameProps, data); + } + + return result; + } } diff --git a/libs/json-api/json-api-nestjs-microorm/src/lib/service/mikro-orm-format.error.service.ts b/libs/json-api/json-api-nestjs-microorm/src/lib/service/mikro-orm-format.error.service.ts index d2694b6f..4760dd39 100644 --- a/libs/json-api/json-api-nestjs-microorm/src/lib/service/mikro-orm-format.error.service.ts +++ b/libs/json-api/json-api-nestjs-microorm/src/lib/service/mikro-orm-format.error.service.ts @@ -86,12 +86,14 @@ export class MikroOrmFormatErrorService extends ErrorFormatService { } private prepareDataBaseError(error: DriverException) { + + const driverName = this.em + .getPlatform() + .getConfig() + .getDriver() + .constructor.name if ( - !this.em - .getPlatform() - .getConfig() - .getDriver() - .constructor.name.startsWith('Postgre') + !(driverName.startsWith('Postgre') || driverName.startsWith('PGliteDriver')) ) { return super.formatError(error); } diff --git a/libs/json-api/json-api-nestjs-microorm/vite.config.mts b/libs/json-api/json-api-nestjs-microorm/vite.config.mts index 0df9b006..71cb8a8d 100644 --- a/libs/json-api/json-api-nestjs-microorm/vite.config.mts +++ b/libs/json-api/json-api-nestjs-microorm/vite.config.mts @@ -46,7 +46,7 @@ export default defineConfig(() => ({ reporters: ['default'], coverage: { enabled: true, - reporter: ['json'], + reporter: ['json-summary'], reportsDirectory: '../../../coverage/json-api-nestjs-microorm', provider: 'v8' as const, }, diff --git a/libs/json-api/json-api-nestjs-sdk/src/index.ts b/libs/json-api/json-api-nestjs-sdk/src/index.ts index 7713ed70..2af2401a 100644 --- a/libs/json-api/json-api-nestjs-sdk/src/index.ts +++ b/libs/json-api/json-api-nestjs-sdk/src/index.ts @@ -7,4 +7,4 @@ export { export { JsonApiUtilsService, JsonApiSdkService } from './lib/service'; export * from './lib/json-api-js'; export { adapterForAxios } from './lib/utils'; -export { AtomicOperations, Operands, QueryParams } from './lib/types'; +export { AtomicOperations, Operands, QueryParams, JsonConfig, Filter, Includes, Sort, Pagination, Fields } from './lib/types'; diff --git a/libs/json-api/json-api-nestjs-sdk/src/lib/types/query-params.ts b/libs/json-api/json-api-nestjs-sdk/src/lib/types/query-params.ts index 66a4aa8c..ffc523c3 100644 --- a/libs/json-api/json-api-nestjs-sdk/src/lib/types/query-params.ts +++ b/libs/json-api/json-api-nestjs-sdk/src/lib/types/query-params.ts @@ -30,9 +30,9 @@ type RelationSort = { [K in RelationKeys]?: SortForEntity>; }; -type Sort = TargetSort & RelationSort; +export type Sort = TargetSort & RelationSort; -type Fields = TargetField & RelationField; +export type Fields = TargetField & RelationField; export type Pagination = { number: number; @@ -48,7 +48,7 @@ type EntityFilter = { }; type TargetFilter = { - target: EntityFilter & TargetRelationFilter; + target?: EntityFilter & TargetRelationFilter; }; type RelationFilter = { diff --git a/libs/json-api/json-api-nestjs-sdk/src/lib/utils/generate-atomic-body.spec.ts b/libs/json-api/json-api-nestjs-sdk/src/lib/utils/generate-atomic-body.spec.ts index ad28e07e..aebee3f8 100644 --- a/libs/json-api/json-api-nestjs-sdk/src/lib/utils/generate-atomic-body.spec.ts +++ b/libs/json-api/json-api-nestjs-sdk/src/lib/utils/generate-atomic-body.spec.ts @@ -99,7 +99,7 @@ describe('GenerateAtomicBody', () => { const expectedBodyData = { op: Operation.add, - ref: { type: 'book-list', tmpId: entity.id }, + ref: { type: 'book-list', tmpId: entity.id, id: 'tmpId' }, data: { attributes: { text: entity.text, diff --git a/libs/json-api/json-api-nestjs-sdk/src/lib/utils/generate-atomic-body.ts b/libs/json-api/json-api-nestjs-sdk/src/lib/utils/generate-atomic-body.ts index 6ad80cef..bf96742e 100644 --- a/libs/json-api/json-api-nestjs-sdk/src/lib/utils/generate-atomic-body.ts +++ b/libs/json-api/json-api-nestjs-sdk/src/lib/utils/generate-atomic-body.ts @@ -46,6 +46,9 @@ export class GenerateAtomicBody< const rel = relationType ? { relationship: String(relationType) } : {}; const tmpId = op === 'add' && id && !relationType ? { tmpId: String(id) } : {}; + if (op === 'add' && id && !relationType) { + idObj['id'] = String(id); + } this.bodyData = { op, ref: { type, ...idObj, ...rel, ...tmpId }, diff --git a/libs/json-api/json-api-nestjs-sdk/vite.config.mts b/libs/json-api/json-api-nestjs-sdk/vite.config.mts index 8eef6bc8..096e68b2 100644 --- a/libs/json-api/json-api-nestjs-sdk/vite.config.mts +++ b/libs/json-api/json-api-nestjs-sdk/vite.config.mts @@ -37,7 +37,7 @@ export default defineConfig(() => ({ reporters: ['default'], coverage: { enabled: true, - reporter: ['json'], + reporter: ['json-summary'], reportsDirectory: '../../../coverage/json-api-nestjs-sdk', provider: 'v8' as const, }, diff --git a/libs/json-api/json-api-nestjs-shared/src/lib/types/entity-type.ts b/libs/json-api/json-api-nestjs-shared/src/lib/types/entity-type.ts index bc40ceed..1de8222d 100644 --- a/libs/json-api/json-api-nestjs-shared/src/lib/types/entity-type.ts +++ b/libs/json-api/json-api-nestjs-shared/src/lib/types/entity-type.ts @@ -13,8 +13,8 @@ export type CastIteratorType = T extends { type RelationCheck = T extends never ? 0 : T extends Promise - ? HasId - : HasId, IdKey>; + ? RelationCheck + : HasId, undefined>, IdKey>; export type RelationKeys = { [K in keyof E]: Exclude extends never diff --git a/libs/json-api/json-api-nestjs-shared/vite.config.mts b/libs/json-api/json-api-nestjs-shared/vite.config.mts index 214acd26..726cf5f9 100644 --- a/libs/json-api/json-api-nestjs-shared/vite.config.mts +++ b/libs/json-api/json-api-nestjs-shared/vite.config.mts @@ -37,7 +37,7 @@ export default defineConfig(() => ({ reporters: ['default'], coverage: { enabled: true, - reporter: ['json'], + reporter: ['json-summary'], reportsDirectory: '../../../coverage/json-api-nestjs-shared', provider: 'v8' as const, }, diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/mock-utils/pull-data.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/mock-utils/pull-data.ts index 5499bb48..1d7b6e3c 100644 --- a/libs/json-api/json-api-nestjs-typeorm/src/lib/mock-utils/pull-data.ts +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/mock-utils/pull-data.ts @@ -47,7 +47,7 @@ export async function pullUser(userPero: Repository) { user.firstName = faker.person.firstName(); user.lastName = faker.person.lastName(); user.isActive = faker.datatype.boolean(); - user.login = faker.internet.userName({ + user.login = faker.internet.username({ lastName: user.lastName, firstName: user.firstName, }); diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/acl-rules-to-typeorm.spec.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/acl-rules-to-typeorm.spec.ts new file mode 100644 index 00000000..8469d130 --- /dev/null +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/acl-rules-to-typeorm.spec.ts @@ -0,0 +1,158 @@ +import { DataSource, Repository, Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { PGliteDriver } from 'typeorm-pglite'; +import { applyAclRulesToQueryBuilder } from './acl-rules-to-typeorm'; +import { TypeormUtilsService } from '../service'; + +@Entity() +class User { + @PrimaryGeneratedColumn() + id!: number; + + @Column() + name!: string; +} + +@Entity() +class TestEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column() + authorId!: number; + + @Column() + status!: string; + + @Column({ default: false }) + isPublic!: boolean; + + @Column() + lastName!: string; + + @ManyToOne(() => User) + @JoinColumn() + user?: User; +} + +describe('applyAclRulesToQueryBuilder', () => { + let dataSource: DataSource; + let repository: Repository; + let typeormUtils: TypeormUtilsService; + + beforeAll(async () => { + dataSource = new DataSource({ + type: 'postgres', + driver: new PGliteDriver({}).driver, + entities: [TestEntity, User], + synchronize: true, + }); + + await dataSource.initialize(); + repository = dataSource.getRepository(TestEntity); + typeormUtils = new (TypeormUtilsService as any)(repository); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + it('should handle $eq operator', () => { + const rulesForQuery = { authorId: { $eq: 123 } }; + + const queryBuilder = repository.createQueryBuilder('TestEntity'); + const brackets = applyAclRulesToQueryBuilder(rulesForQuery, typeormUtils); + queryBuilder.where(brackets); + + const sql = queryBuilder.getQuery(); + const params = queryBuilder.getParameters(); + + expect(sql).toContain('"TestEntity"."authorId" = :aclParam_1'); + expect(params['aclParam_1']).toBe(123); + }); + + it('should handle primitive value (direct equality)', () => { + const rulesForQuery = { status: 'published' }; + + const queryBuilder = repository.createQueryBuilder('TestEntity'); + const brackets = applyAclRulesToQueryBuilder(rulesForQuery, typeormUtils); + queryBuilder.where(brackets); + + const sql = queryBuilder.getQuery(); + const params = queryBuilder.getParameters(); + + expect(sql).toContain('"TestEntity"."status" = :aclParam_1'); + expect(params['aclParam_1']).toBe('published'); + }); + + it('should handle $in operator', () => { + const rulesForQuery = { status: { $in: ['published', 'archived'] } }; + + const queryBuilder = repository.createQueryBuilder('TestEntity'); + const brackets = applyAclRulesToQueryBuilder(rulesForQuery, typeormUtils); + queryBuilder.where(brackets); + + const sql = queryBuilder.getQuery(); + const params = queryBuilder.getParameters(); + + expect(sql).toContain('"TestEntity"."status" IN (:...aclParam_1)'); + expect(params['aclParam_1']).toEqual(['published', 'archived']); + }); + + it('should handle $or operator', () => { + const rulesForQuery = { $or: [{ authorId: 123 }, { status: 'published' }] }; + + const queryBuilder = repository.createQueryBuilder('TestEntity'); + const brackets = applyAclRulesToQueryBuilder(rulesForQuery, typeormUtils); + queryBuilder.where(brackets); + + const sql = queryBuilder.getQuery(); + const params = queryBuilder.getParameters(); + + // Should contain both conditions with OR logic + expect(sql).toContain('"TestEntity"."authorId" = :aclParam_1'); + expect(sql).toContain('"TestEntity"."status" = :aclParam_2'); + expect(sql).toContain('OR'); // Should have OR operator + expect(params['aclParam_1']).toBe(123); + expect(params['aclParam_2']).toBe('published'); + }); + + it('should handle complex query with $or, $and, $not and relations', () => { + const rulesForQuery = { + $or: [ + { isPublic: { $eq: true } }, + { user: { id: { $eq: 3 } } }, + { lastName: 'test' }, + ], + $and: [ + { $not: { lastName: 'test2' } }, + ], + }; + + const queryBuilder = repository.createQueryBuilder('TestEntity'); + const brackets = applyAclRulesToQueryBuilder(rulesForQuery, typeormUtils); + queryBuilder.where(brackets); + + const sql = queryBuilder.getQuery(); + const params = queryBuilder.getParameters(); + + // Check $or conditions + expect(sql).toContain('OR'); + expect(sql).toContain('"TestEntity"."isPublic" = :aclParam_1'); + expect(params['aclParam_1']).toBe(true); + + // Check relation field (user.id) + expect(sql).toContain('user'); // Should have user relation alias + expect(sql).toContain('id'); // Should have id field + expect(params['aclParam_2']).toBe(3); + + // Check primitive lastName + expect(sql).toContain('"TestEntity"."lastName" = :aclParam_3'); + expect(params['aclParam_3']).toBe('test'); + + // Check $and and $not + expect(sql).toContain('AND'); + // $not is not fully supported, should have warning logged + }); +}); diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/acl-rules-to-typeorm.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/acl-rules-to-typeorm.ts new file mode 100644 index 00000000..7da5bc03 --- /dev/null +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/acl-rules-to-typeorm.ts @@ -0,0 +1,367 @@ +import { Brackets, WhereExpressionBuilder } from 'typeorm'; +import { TypeormUtilsService } from '../service'; +import { OperandsMapExpression, EXPRESSION } from '../type'; + +/** + * Maps ACL operators (with $) to TypeORM operators (without $) + */ +const ACL_TO_TYPEORM_OPERATOR: Record = { + '$eq': 'eq', + '$ne': 'ne', + '$gt': 'gt', + '$gte': 'gte', + '$lt': 'lt', + '$lte': 'lte', + '$in': 'in', + '$nin': 'nin', + '$re': 'regexp', + '$contains': 'contains', +}; + +/** + * Extract all relation field names from ACL rulesForQuery + * @param rules - ACL rules object + * @param typeormUtils - TypeORM utils service + * @returns Set of relation field names used in the rules + */ +export function extractRelationsFromRules( + rules: Record, + typeormUtils: TypeormUtilsService +): Set { + const relations = new Set(); + + const processObject = (obj: Record) => { + for (const [key, value] of Object.entries(obj)) { + // Skip logical operators + if (key === '$or' || key === '$and' || key === '$not') { + if (Array.isArray(value)) { + value.forEach((item) => { + if (typeof item === 'object' && item !== null) { + processObject(item as Record); + } + }); + } else if (typeof value === 'object' && value !== null) { + processObject(value as Record); + } + continue; + } + + // Check if this is a relation field + // @ts-ignore + const isRelation = typeormUtils.relationFields.includes(key as any); + if (isRelation) { + relations.add(key); + } + } + }; + + processObject(rules); + return relations; +} + +/** + * Converts ACL rulesForQuery (MikroORM-like format) to TypeORM QueryBuilder conditions + * + * @param rulesForQuery - MongoDB-like query object from ExtendAbility.getQueryObject() + * @param typeormUtils - TypeORM utils service for getting aliases and paths + * @param hasExistingWhere - Whether queryBuilder already has WHERE conditions + * @returns Callback function for queryBuilder.andWhere() or queryBuilder.where() + * + * @example + * // With existing WHERE + * const callback = applyAclRulesToQueryBuilder( + * { authorId: 123, profile: { isPublic: true } }, + * typeormUtils, + * true + * ); + * queryBuilder.andWhere(callback); + * + * @example + * // Without existing WHERE + * const callback = applyAclRulesToQueryBuilder( + * { authorId: 123 }, + * typeormUtils, + * false + * ); + * callback(queryBuilder); + */ +export function applyAclRulesToQueryBuilder( + rulesForQuery: Record, + typeormUtils: TypeormUtilsService, +): Brackets { + let paramCounter = 0; + const getParamName = (): string => { + paramCounter++; + return `aclParam_${paramCounter}`; + }; + + /** + * Recursively process rules and apply to query builder + */ + const processRules = ( + qb: WhereExpressionBuilder, + rules: Record, + isFirstCondition = true + ): void => { + for (const [key, value] of Object.entries(rules)) { + // Handle logical operators + if (key === '$and' && Array.isArray(value)) { + qb.andWhere( + new Brackets((subQb) => { + for (const condition of value) { + processRules(subQb, condition as Record, false); + } + }) + ); + continue; + } + + if (key === '$or' && Array.isArray(value)) { + // Build OR conditions as a single WHERE clause with manual parentheses + const orConditions: string[] = []; + const orParams: Record = {}; + + value.forEach((condition) => { + const cond = condition as Record; + + Object.entries(cond).forEach(([field, val]) => { + // Check if this is a relation field + // @ts-ignore + const isRelation = typeormUtils.relationFields.includes(field as any); + + if (isRelation && typeof val === 'object' && val !== null && !Array.isArray(val)) { + // Handle relation fields: { user: { id: { $eq: 3 } } } + const relationAlias = typeormUtils.getAliasForRelation(field as any); + + for (const [nestedKey, nestedValue] of Object.entries(val as Record)) { + const fieldPath = `${relationAlias}.${nestedKey}`; + + if (nestedValue === null || typeof nestedValue !== 'object' || Array.isArray(nestedValue)) { + const paramName = getParamName(); + orConditions.push(`${fieldPath} = :${paramName}`); + orParams[paramName] = nestedValue; + } else { + // Handle operators in nested field + const operators = nestedValue as Record; + const [aclOperator, operatorValue] = Object.entries(operators)[0]; + const typeormOperator = ACL_TO_TYPEORM_OPERATOR[aclOperator]; + + if (typeormOperator) { + const sqlTemplate = OperandsMapExpression[typeormOperator as keyof typeof OperandsMapExpression]; + if (sqlTemplate) { + const paramName = getParamName(); + const sqlExpression = sqlTemplate.replace(EXPRESSION, paramName); + orConditions.push(`${fieldPath} ${sqlExpression}`); + orParams[paramName] = operatorValue; + } + } + } + } + } else { + // Handle entity fields + const fieldPath = `${typeormUtils.currentAlias}.${field}`; + + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + const paramName = getParamName(); + orConditions.push(`${fieldPath} = :${paramName}`); + orParams[paramName] = val; + } else { + // Handle operators + const operators = val as Record; + const [aclOperator, operatorValue] = Object.entries(operators)[0]; + const typeormOperator = ACL_TO_TYPEORM_OPERATOR[aclOperator]; + + if (typeormOperator) { + const sqlTemplate = OperandsMapExpression[typeormOperator as keyof typeof OperandsMapExpression]; + if (sqlTemplate) { + const paramName = getParamName(); + const sqlExpression = sqlTemplate.replace(EXPRESSION, paramName); + orConditions.push(`${fieldPath} ${sqlExpression}`); + orParams[paramName] = operatorValue; + } + } + } + } + }); + }); + + if (orConditions.length > 0) { + // Wrap in parentheses and join with OR + const orClause = `(${orConditions.join(' OR ')})`; + const whereMethod = isFirstCondition ? 'where' : 'andWhere'; + qb[whereMethod](orClause, orParams); + isFirstCondition = false; + } + + continue; + } + + if (key === '$not') { + // Build NOT conditions - collect all conditions and wrap in NOT (...) + const notConditions: string[] = []; + const notParams: Record = {}; + + const notRules = value as Record; + + for (const [notKey, notValue] of Object.entries(notRules)) { + // Check if this is a relation field + // @ts-ignore + const isRelation = typeormUtils.relationFields.includes(notKey as any); + + if (isRelation && typeof notValue === 'object' && notValue !== null && !Array.isArray(notValue)) { + // Handle relation fields + const relationAlias = typeormUtils.getAliasForRelation(notKey as any); + + for (const [nestedKey, nestedValue] of Object.entries(notValue as Record)) { + const fieldPath = `${relationAlias}.${nestedKey}`; + + if (nestedValue === null || typeof nestedValue !== 'object' || Array.isArray(nestedValue)) { + const paramName = getParamName(); + notConditions.push(`${fieldPath} = :${paramName}`); + notParams[paramName] = nestedValue; + } else { + // Handle operators + const operators = nestedValue as Record; + const [aclOperator, operatorValue] = Object.entries(operators)[0]; + const typeormOperator = ACL_TO_TYPEORM_OPERATOR[aclOperator]; + + if (typeormOperator) { + const sqlTemplate = OperandsMapExpression[typeormOperator as keyof typeof OperandsMapExpression]; + if (sqlTemplate) { + const paramName = getParamName(); + const sqlExpression = sqlTemplate.replace(EXPRESSION, paramName); + notConditions.push(`${fieldPath} ${sqlExpression}`); + notParams[paramName] = operatorValue; + } + } + } + } + } else { + // Handle entity fields + const fieldPath = `${typeormUtils.currentAlias}.${notKey}`; + + if (notValue === null || typeof notValue !== 'object' || Array.isArray(notValue)) { + const paramName = getParamName(); + notConditions.push(`${fieldPath} = :${paramName}`); + notParams[paramName] = notValue; + } else { + // Handle operators + const operators = notValue as Record; + const [aclOperator, operatorValue] = Object.entries(operators)[0]; + const typeormOperator = ACL_TO_TYPEORM_OPERATOR[aclOperator]; + + if (typeormOperator) { + const sqlTemplate = OperandsMapExpression[typeormOperator as keyof typeof OperandsMapExpression]; + if (sqlTemplate) { + const paramName = getParamName(); + const sqlExpression = sqlTemplate.replace(EXPRESSION, paramName); + notConditions.push(`${fieldPath} ${sqlExpression}`); + notParams[paramName] = operatorValue; + } + } + } + } + } + + if (notConditions.length > 0) { + // Wrap in NOT (...) + const notClause = `NOT (${notConditions.join(' AND ')})`; + qb.andWhere(notClause, notParams); + } + + continue; + } + + // Check if this is a relation field + // @ts-ignore + const isRelation = typeormUtils.relationFields.includes(key as any); + + if (isRelation && typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Handle relation fields: { profile: { isPublic: true } } + const relationAlias = typeormUtils.getAliasForRelation(key as any); + + for (const [nestedKey, nestedValue] of Object.entries(value as Record)) { + processFieldCondition( + qb, + `${relationAlias}.${nestedKey}`, + nestedValue, + isFirstCondition && Object.keys(rules).indexOf(key) === 0 + ); + isFirstCondition = false; + } + } else { + // Handle entity fields: { authorId: 123, status: { $in: [...] } } + const fieldPath = `${typeormUtils.currentAlias}.${key}`; + processFieldCondition(qb, fieldPath, value, isFirstCondition); + isFirstCondition = false; + } + } + }; + + /** + * Process a single field condition (with or without operators) + */ + const processFieldCondition = ( + qb: WhereExpressionBuilder, + fieldPath: string, + value: unknown, + isFirst: boolean + ): void => { + const whereMethod = isFirst ? 'where' : 'andWhere'; + + // Primitive value - direct equality + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + const paramName = getParamName(); + qb[whereMethod](`${fieldPath} = :${paramName}`, { [paramName]: value }); + return; + } + + // Object with operators: { $in: [...], $gt: 10, etc. } + const operators = value as Record; + + for (const [aclOperator, operatorValue] of Object.entries(operators)) { + const paramName = getParamName(); + + // Convert ACL operator ($eq) to TypeORM operator (eq) + const typeormOperator = ACL_TO_TYPEORM_OPERATOR[aclOperator]; + + if (!typeormOperator) { + console.warn(`[ACL] Unknown operator "${aclOperator}" in rulesForQuery`); + continue; + } + + // Get SQL expression template from OperandsMapExpression + const sqlTemplate = OperandsMapExpression[typeormOperator as keyof typeof OperandsMapExpression]; + + if (!sqlTemplate) { + console.warn(`[ACL] No SQL template for operator "${typeormOperator}"`); + continue; + } + + // Replace EXPRESSION placeholder with paramName + const sqlExpression = sqlTemplate.replace(EXPRESSION, paramName); + + // Apply condition to query builder + qb[whereMethod](`${fieldPath} ${sqlExpression}`, { [paramName]: operatorValue }); + } + }; + + // Return callback function + // if (hasExistingWhere) { + // // If there's existing WHERE, wrap in Brackets to isolate ACL conditions + // return (qb: WhereExpressionBuilder) => { + // qb.andWhere( + // + // ); + // }; + // } else { + // // If no existing WHERE, process rules directly + // return (qb: WhereExpressionBuilder) => { + // processRules(qb, rulesForQuery, true); + // }; + // } + return new Brackets((subQb) => { + // Inside Brackets, MUST use TRUE - first condition needs .where() + processRules(subQb, rulesForQuery, true); + }); +} diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/index.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/index.ts index 65506d34..086db59b 100644 --- a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/index.ts +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-helper/index.ts @@ -1,6 +1,8 @@ import { EntityParam, TypeField } from '@klerick/json-api-nestjs'; import { Repository } from 'typeorm'; +export * from './acl-rules-to-typeorm'; + export const getRelation = (repository: Repository) => repository.metadata.relations.map((i) => { return i.propertyName; @@ -182,3 +184,5 @@ export const getArrayType = ( return acum; }, {} as any); }; + +export { applyAclRulesToQueryBuilder, extractRelationsFromRules } from './acl-rules-to-typeorm'; diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/get-all/get-all.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/get-all/get-all.ts index 951881e7..283f52b3 100644 --- a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/get-all/get-all.ts +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/get-all/get-all.ts @@ -8,6 +8,8 @@ import { SUB_QUERY_ALIAS_FOR_PAGINATION, } from '../../constants'; +import { applyAclRulesToQueryBuilder } from '../../orm-helper'; + type OrderByCondition = Record; function getSortObject(params: any, relationName: string) { @@ -19,10 +21,14 @@ function getSortObject(params: any, relationName: string) { export async function getAll( this: TypeOrmService, - query: Query -): Promise> { + query: Query, + transformData?: boolean, + additionalQueryParams?: Record +): Promise< + ResourceObject | { totalItems: number; items: E[] } +> { const { fields, include, sort, page } = query; - + let hasExistingWhere = false; let defaultSortObject: OrderByCondition = { [`${ this.typeormUtilsService.currentAlias @@ -86,6 +92,7 @@ export async function getAll( .orderBy(defaultSortObject); for (const i in expressionArray) { + hasExistingWhere = true const { params, alias, selectInclude, expression } = expressionArray[i]; const expressionTempArray: string[] = []; if (alias) { @@ -116,7 +123,28 @@ export async function getAll( currentIncludeAlias ); } + if (additionalQueryParams) { + const brackets = applyAclRulesToQueryBuilder( + additionalQueryParams, + this.typeormUtilsService, + ); + for (const item of include || []) { + const currentIncludeAlias = this.typeormUtilsService.getAliasForRelation( + item as any + ); + if (!currentIncludeAlias) continue; + queryBuilderForCount.leftJoin( + this.typeormUtilsService.getAliasPath(item), + currentIncludeAlias + ); + } + if (hasExistingWhere) { + queryBuilderForCount.andWhere(brackets); + } else { + queryBuilderForCount.where(brackets); + } + } const count = await queryBuilderForCount.getCount(); const meta = { pageNumber: page.number, @@ -125,10 +153,15 @@ export async function getAll( }; if (count === 0) { - return { - meta, - data: [], - }; + return transformData + ? { + meta, + data: [], + } + : { + totalItems: 0, + items: [], + }; } const aliasForIdResultPagination = this.typeormUtilsService.getAliasPath( @@ -181,9 +214,13 @@ export async function getAll( if (include) { for (const rel of include) { const currentIncludeAlias = - this.typeormUtilsService.getAliasForRelation(rel as keyof RelationAlias); + this.typeormUtilsService.getAliasForRelation( + rel as unknown as keyof RelationAlias + ); const primaryColumnName = - this.typeormUtilsService.getPrimaryColumnForRel(rel as keyof RelationAlias); + this.typeormUtilsService.getPrimaryColumnForRel( + rel as unknown as keyof RelationAlias + ); selectFields.add(`${currentIncludeAlias}.${primaryColumnName}`); } } @@ -249,6 +286,12 @@ export async function getAll( ); } const resultData = await resultQuery.getMany(); + if (!transformData) { + return { + totalItems: count, + items: resultData, + }; + } const { included, data } = this.transformDataService.transformData( resultData, query diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/get-one/get-one.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/get-one/get-one.ts index 81591dca..116f6db6 100644 --- a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/get-one/get-one.ts +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/get-one/get-one.ts @@ -2,12 +2,18 @@ import { NotFoundException } from '@nestjs/common'; import { ValidateQueryError, QueryOne } from '@klerick/json-api-nestjs'; import { ObjectTyped, ResourceObject } from '@klerick/json-api-nestjs-shared'; import { RelationAlias, TypeOrmService } from '../../service'; +import { + applyAclRulesToQueryBuilder, + extractRelationsFromRules, +} from '../../orm-helper'; export async function getOne( this: TypeOrmService, id: number | string, - query: QueryOne -): Promise> { + query: QueryOne, + transformData?: boolean, + additionalQueryParams?: Record +): Promise | E> { const { include, fields } = query; const selectFields = new Set(); const builder = this.repository.createQueryBuilder( @@ -34,7 +40,9 @@ export async function getOne( selectFields.add( this.typeormUtilsService.getAliasPath( itemFieldRel, - this.typeormUtilsService.getAliasForRelation(rel as keyof RelationAlias) + this.typeormUtilsService.getAliasForRelation( + rel as keyof RelationAlias + ) ) ); } @@ -44,7 +52,7 @@ export async function getOne( if (include) { for (const relFromLoop of include) { - const rel = relFromLoop as keyof RelationAlias + const rel = relFromLoop as unknown as keyof RelationAlias; const currentIncludeAlias = this.typeormUtilsService.getAliasForRelation(rel); @@ -67,16 +75,51 @@ export async function getOne( builder.select([...selectFields]); } const paramsId = 'paramsId'; - const result = await builder - .where( - `${this.typeormUtilsService.getAliasPath( - this.typeormUtilsService.currentPrimaryColumn - )} = :${paramsId}`, - { - [paramsId]: id, + builder.where( + `${this.typeormUtilsService.getAliasPath( + this.typeormUtilsService.currentPrimaryColumn + )} = :${paramsId}`, + { + [paramsId]: id, + } + ); + + if (additionalQueryParams) { + + // Extract relations from ACL rules + const aclRelations = extractRelationsFromRules( + additionalQueryParams, + this.typeormUtilsService + ); + + // Add JOINs for ACL relations that aren't already in the query + for (const rel of aclRelations) { + const relationAlias = this.typeormUtilsService.getAliasForRelation( + rel as any + ); + + // Check if this alias already exists in the query + const aliasExists = builder.expressionMap.aliases.some( + (alias) => alias.name === relationAlias + ); + + if (!aliasExists) { + builder.leftJoin( + this.typeormUtilsService.getAliasPath(rel as any), + relationAlias + ); } - ) - .getOne(); + } + + builder.andWhere( + applyAclRulesToQueryBuilder( + additionalQueryParams, + this.typeormUtilsService + ) + ); + } + + const result = await builder.getOne(); if (!result) { const error: ValidateQueryError = { @@ -86,6 +129,9 @@ export async function getOne( }; throw new NotFoundException([error]); } + + if (!transformData) return result; + const { included, data } = this.transformDataService.transformData( result, query diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/patch-one/patch-one.spec.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/patch-one/patch-one.spec.ts index d931d7fc..2ecdfa37 100644 --- a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/patch-one/patch-one.spec.ts +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/patch-one/patch-one.spec.ts @@ -52,7 +52,7 @@ describe('patchOne', () => { beforeEach(async () => { firstName = faker.person.firstName(); isActive = false; - login = faker.internet.userName({ + login = faker.internet.username({ lastName: firstName, firstName: faker.person.lastName(), }); diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/post-one/post-one.spec.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/post-one/post-one.spec.ts index 269f80c2..8b0a051d 100644 --- a/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/post-one/post-one.spec.ts +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/orm-methods/post-one/post-one.spec.ts @@ -59,7 +59,7 @@ describe('postOne', () => { ); firstName = faker.person.firstName(); isActive = false; - login = faker.internet.userName({ + login = faker.internet.username({ lastName: firstName, firstName: faker.person.lastName(), }); diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/service/type-orm.service.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/service/type-orm.service.ts index 529b0859..075984c4 100644 --- a/libs/json-api/json-api-nestjs-typeorm/src/lib/service/type-orm.service.ts +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/service/type-orm.service.ts @@ -10,11 +10,13 @@ import { EntityControllerParam, JsonApiTransformerService, CONTROLLER_OPTIONS_TOKEN, + Relationships, } from '@klerick/json-api-nestjs'; import { ResourceObject, RelationKeys, ResourceObjectRelationships, + ObjectTyped, } from '@klerick/json-api-nestjs-shared'; import { Repository } from 'typeorm'; @@ -46,14 +48,26 @@ export class TypeOrmService public config!: EntityControllerParam; @Inject(CURRENT_ENTITY_REPOSITORY) public repository!: Repository; - getAll( + async getAll( query: Query - ): Promise> { + ): Promise>; + async getAll( + query: Query, + transformData?: boolean, + additionalQueryParams?: Record + ): Promise>; + async getAll( + query: Query, + transformData = true, + additionalQueryParams?: Record + ): Promise< + ResourceObject | { totalItems: number; items: E[] } + > { return getAll.call< TypeOrmService, Parameters>, ReturnType> - >(this, query); + >(this, query, transformData, additionalQueryParams); } deleteOne(id: number | string): Promise { @@ -76,15 +90,27 @@ export class TypeOrmService >(this, id, rel, input); } - getOne( + async getOne( id: number | string, query: QueryOne - ): Promise> { + ): Promise>; + async getOne( + id: number | string, + query: QueryOne, + transformData?: boolean, + additionalQueryParams?: Record + ): Promise | E>; + async getOne( + id: number | string, + query: QueryOne, + transformData = true, + additionalQueryParams?: Record + ): Promise | E> { return getOne.call< TypeOrmService, Parameters>, ReturnType> - >(this, id, query); + >(this, id, query, transformData, additionalQueryParams); } getRelationship>( @@ -142,4 +168,23 @@ export class TypeOrmService ReturnType> >(this, id, rel, input); } + + async loadRelations( + relationships: NonNullable> + ): Promise<{ + [K in RelationKeys]: E[K]; + }> { + const result = {} as { [K in RelationKeys ]: E[K]; }; + + for await (const item of this.typeormUtilsService.asyncIterateFindRelationships( + relationships as any + )) { + const itemProps = ObjectTyped.entries(item).at(0); + if (!itemProps) continue; + const [nameProps, data] = itemProps; + Reflect.set(result, nameProps, data); + } + + return result; + } } diff --git a/libs/json-api/json-api-nestjs-typeorm/src/lib/type.ts b/libs/json-api/json-api-nestjs-typeorm/src/lib/type.ts index 356bb26e..dbb7b0e0 100644 --- a/libs/json-api/json-api-nestjs-typeorm/src/lib/type.ts +++ b/libs/json-api/json-api-nestjs-typeorm/src/lib/type.ts @@ -23,6 +23,7 @@ export const OperandsMapExpression = { [FilterOperand.nin]: `NOT IN (:...${EXPRESSION})`, [FilterOperand.some]: `&& :${EXPRESSION}`, ilike: `ILIKE :${EXPRESSION}`, + contains: `@> :${EXPRESSION}`, // PostgreSQL array contains operator }; export const OperandMapExpressionForNull = { diff --git a/libs/json-api/json-api-nestjs-typeorm/vite.config.mts b/libs/json-api/json-api-nestjs-typeorm/vite.config.mts index fe84e7d1..5180d7b7 100644 --- a/libs/json-api/json-api-nestjs-typeorm/vite.config.mts +++ b/libs/json-api/json-api-nestjs-typeorm/vite.config.mts @@ -46,7 +46,7 @@ export default defineConfig(() => ({ reporters: ['default'], coverage: { enabled: true, - reporter: ['json'], + reporter: ['json-summary'], reportsDirectory: '../../../coverage/json-api-nestjs-typeorm', provider: 'v8' as const, }, diff --git a/libs/json-api/json-api-nestjs/src/index.ts b/libs/json-api/json-api-nestjs/src/index.ts index a37df706..31317a43 100644 --- a/libs/json-api/json-api-nestjs/src/index.ts +++ b/libs/json-api/json-api-nestjs/src/index.ts @@ -18,6 +18,7 @@ export { export { JsonApiTransformerService, ErrorFormatService, + EntityParamMapService } from './lib/modules/mixin/service'; export { MODULE_OPTIONS_TOKEN, @@ -32,6 +33,7 @@ export { DEFAULT_PAGE_SIZE, DEFAULT_QUERY_PAGE, CURRENT_ENTITY, + METHOD_NAME } from './lib/constants'; export { OrmService, @@ -41,6 +43,7 @@ export { FindOneRowEntity, RunInTransaction, EntityParamMap, + MethodName } from './lib/modules/mixin/types'; export { PatchData, diff --git a/libs/json-api/json-api-nestjs/src/lib/constants/constants.ts b/libs/json-api/json-api-nestjs/src/lib/constants/constants.ts index 1c47554f..ae34ba3b 100644 --- a/libs/json-api/json-api-nestjs/src/lib/constants/constants.ts +++ b/libs/json-api/json-api-nestjs/src/lib/constants/constants.ts @@ -1,3 +1,5 @@ +import { MethodName } from '../modules/mixin/types'; + export const JSON_API_CONTROLLER_POSTFIX = 'JsonApiController'; export const JSON_API_MODULE_POSTFIX = 'JsonApiModule'; export const DEFAULT_CONNECTION_NAME = 'default'; @@ -8,3 +10,16 @@ export const DESC = 'DESC'; export const ASC = 'ASC'; export const SORT_TYPE = [DESC, ASC] as const; +export const METHOD_NAME: { + [P in MethodName]: P; +} = { + getAll: 'getAll', + getOne: 'getOne', + deleteOne: 'deleteOne', + deleteRelationship: 'deleteRelationship', + postOne: 'postOne', + getRelationship: 'getRelationship', + patchOne: 'patchOne', + patchRelationship: 'patchRelationship', + postRelationship: 'postRelationship', +}; diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/atomic-operation.module.ts b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/atomic-operation.module.ts index f8c75fcc..7e8852f0 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/atomic-operation.module.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/atomic-operation.module.ts @@ -19,6 +19,7 @@ import { AsyncIterate, } from './factory'; import { MAP_CONTROLLER_INTERCEPTORS } from './constants'; +import { ErrorFormatService } from '../../modules/mixin/service'; @Module({}) export class AtomicOperationModule implements NestModule { @@ -48,10 +49,16 @@ export class AtomicOperationModule implements NestModule { entityModules: DynamicModule[], commonModule: DynamicModule ): DynamicModule { + + const errorFormat = (commonModule.providers || []).find(i => 'provide' in i && i.provide === ErrorFormatService ) + if (!errorFormat) { + throw new Error('ErrorFormatService not found, should be provide in common orm module') + } return { module: AtomicOperationModule, controllers: [OperationController], providers: [ + errorFormat, ExplorerService, ExecuteService, SwaggerService, @@ -87,3 +94,5 @@ export class AtomicOperationModule implements NestModule { .forRoutes('{*splat}'); } } + + diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/controllers/operation.controller.spec.ts b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/controllers/operation.controller.spec.ts index f7c8ecde..c17a8587 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/controllers/operation.controller.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/controllers/operation.controller.spec.ts @@ -25,6 +25,7 @@ import { import { OperationMethode } from '../types'; import { AsyncLocalStorage } from 'async_hooks'; import { RUN_IN_TRANSACTION_FUNCTION } from '../../../constants'; +import { ErrorFormatService } from '@klerick/json-api-nestjs'; describe('OperationController', () => { let operationController: OperationController; @@ -66,6 +67,10 @@ describe('OperationController', () => { provide: AsyncLocalStorage, useValue: new AsyncLocalStorage(), }, + { + provide: ErrorFormatService, + useValue: {} + } ], }).compile(); diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/execute.service.spec.ts b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/execute.service.spec.ts index 28c03693..a76b805c 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/execute.service.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/execute.service.spec.ts @@ -19,6 +19,7 @@ import { ParamsForExecute } from '../types'; import { AsyncLocalStorage } from 'async_hooks'; import { RUN_IN_TRANSACTION_FUNCTION } from '../../../constants'; import { Mock } from 'vitest'; +import { ErrorFormatService } from '../../mixin/service'; describe('ExecuteService', () => { let service: ExecuteService; @@ -54,6 +55,10 @@ describe('ExecuteService', () => { provide: AsyncLocalStorage, useValue: new AsyncLocalStorage(), }, + { + provide: ErrorFormatService, + useValue: {} + } ], }).compile(); @@ -106,6 +111,7 @@ describe('ExecuteService', () => { { controller: { name: 'TestController' }, methodName: 'someMethod', + module: {} }, ] as unknown as ParamsForExecute[]; const callback = vi.fn().mockReturnValue({ value: 'test' }); @@ -142,6 +148,7 @@ describe('ExecuteService', () => { { controller: { name: 'TestController' }, methodName: 'someMethod', + module: {} }, ] as unknown as ParamsForExecute[]; @@ -180,6 +187,7 @@ describe('ExecuteService', () => { { controller: { name: 'TestController' }, methodName: 'someMethod', + module: {} }, ] as unknown as ParamsForExecute[]; diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/execute.service.ts b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/execute.service.ts index b477b5ee..3638c094 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/execute.service.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/execute.service.ts @@ -5,6 +5,8 @@ import { Injectable, PipeTransform, Type, + InternalServerErrorException, + ForbiddenException, } from '@nestjs/common'; import { INTERCEPTORS_METADATA, @@ -23,6 +25,11 @@ import { InterceptorsConsumer, InterceptorsContextCreator, } from '@nestjs/core/interceptors'; +import { + FORBIDDEN_MESSAGE, + GuardsConsumer, + GuardsContextCreator, +} from '@nestjs/core/guards'; import { Controller } from '@nestjs/common/interfaces'; import { lastValueFrom } from 'rxjs'; import { AsyncLocalStorage } from 'async_hooks'; @@ -36,6 +43,7 @@ import { IterateFactory } from '../factory'; import { TypeFromType, ValidateQueryError } from '../../../types'; import { RunInTransaction } from '../../mixin/types'; import { RUN_IN_TRANSACTION_FUNCTION } from '../../../constants'; +import { ErrorFormatService } from '../../mixin/service'; export function isZodError( param: string | unknown @@ -70,8 +78,10 @@ export class ExecuteService { private mapControllerInterceptor!: MapControllerInterceptor; @Inject(AsyncLocalStorage) private asyncLocalStorage!: AsyncLocalStorage; + @Inject(ErrorFormatService) private errorFormatService!: ErrorFormatService; private _interceptorsContextCreator!: InterceptorsContextCreator; + private _guardsContextCreator!: GuardsContextCreator; get interceptorsContextCreator() { if (!this._interceptorsContextCreator) { @@ -84,7 +94,19 @@ export class ExecuteService { return this._interceptorsContextCreator; } + get guardsContextCreator() { + if (!this._guardsContextCreator) { + this._guardsContextCreator = new GuardsContextCreator( + this.moduleRef.container, + this.moduleRef.applicationConfig + ); + } + + return this._guardsContextCreator; + } + private interceptorsConsumer = new InterceptorsConsumer(); + private guardsConsumer = new GuardsConsumer(); async run(params: ParamsForExecute[], tmpIds: (string | number)[]) { return this.runInTransaction(() => this.executeOperations(params, tmpIds)); @@ -116,14 +138,25 @@ export class ExecuteService { const itemReplace = this.replaceTmpIds(paramsForExecute, tmpIdsMap); const body = itemReplace.at(-1); - const currentTmpId = tmpIds[i]; - + // First operation doesn't have tmpId' + const currentTmpId = i !== 0 ? tmpIds[i] : undefined; if (methodName === 'postOne' && currentTmpId && body) { if (typeof body === 'object' && 'attributes' in body) { body['id'] = `${currentTmpId}`; itemReplace[itemReplace.length - 1]; } } + const guarFunction = this.getCanActivateFn( + controller, + controller[methodName], + currentParams.module + ); + + if (guarFunction) { + await guarFunction( + Object.values(this.asyncLocalStorage.getStore() || {}) + ); + } const interceptors = this.getInterceptorsArray( controller, @@ -164,6 +197,32 @@ export class ExecuteService { return resultArray; } + private getCanActivateFn( + controller: Controller, + callback: (...arg: any) => any, + module: ParamsForExecute['module'] + ) { + const guards = this.guardsContextCreator.create( + controller, + callback, + module.token + ); + + const canActivateFn = async (args: any[]) => { + const canActivate = await this.guardsConsumer.tryActivate( + guards, + args, + controller, + callback, + 'http' + ); + if (!canActivate) { + throw new ForbiddenException(FORBIDDEN_MESSAGE); + } + }; + return guards.length ? canActivateFn : null; + } + private getInterceptorsArray( controller: Controller, callback: (...arg: any) => any, @@ -293,7 +352,24 @@ export class ExecuteService { } throw new HttpException(response, e.getStatus()); } - throw e; + const formatError = this.errorFormatService.formatError(e); + + if (formatError instanceof InternalServerErrorException) { + throw formatError; + } + const response = formatError.getResponse(); + if ( + typeof response === 'object' && + 'message' in response && + Array.isArray(response['message']) + ) { + response['message'] = response['message'].map((m: any) => { + m['path'] = [KEY_MAIN_INPUT_SCHEMA, `${i}`, ...m['path']]; + return m; + }); + throw new HttpException(response, formatError.getStatus()); + } + throw formatError; } private async runOneOperation( diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/explorer.service.spec.ts b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/explorer.service.spec.ts index 51fe575d..26d03719 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/explorer.service.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/explorer.service.spec.ts @@ -57,11 +57,16 @@ describe('ExplorerService', () => { }); describe('getMethodNameByParam()', () => { - it('should return the correct method name for given parameters', () => { + it('should return the correct method name for given parameters: postRelationship', () => { expect(service.getMethodNameByParam(Operation.add, 'id', 'rel')).toBe( 'postRelationship' ); }); + it('should return the correct method name for given parameters: postOne', () => { + expect(service.getMethodNameByParam(Operation.add, 'id')).toBe( + 'postOne' + ); + }); }); describe('getParamsForMethod()', () => { diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/explorer.service.ts b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/explorer.service.ts index be3e680a..9e3af56f 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/explorer.service.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/service/explorer.service.ts @@ -48,7 +48,7 @@ export class ExplorerService { ): OperationMethode { switch (operation) { case Operation.add: - return id ? 'postRelationship' : 'postOne'; + return rel ? 'postRelationship' : 'postOne'; case Operation.remove: return rel ? 'deleteRelationship' : 'deleteOne'; case Operation.update: diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/utils/zod/zod-helper.ts b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/utils/zod/zod-helper.ts index a4e2c17f..7c765751 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/utils/zod/zod-helper.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/atomic-operation/utils/zod/zod-helper.ts @@ -36,6 +36,7 @@ export const zodAdd = (type: T) => .object({ type: z.literal(type), tmpId: z.union([z.number(), z.string()]).optional(), + id: z.string().optional(), }) .strict(), data: zodGeneralData, diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/config/bindings.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/config/bindings.ts index bfe6c55d..f8b44e14 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/config/bindings.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/config/bindings.ts @@ -2,7 +2,11 @@ import { Body, Param, Query, RequestMethod } from '@nestjs/common'; import { BindingsConfig } from '../types'; import { JsonBaseController } from '../controllers/json-base.controller'; -import { PARAMS_RELATION_NAME, PARAMS_RESOURCE_ID } from '../../../constants'; +import { + METHOD_NAME, + PARAMS_RELATION_NAME, + PARAMS_RESOURCE_ID, +} from '../../../constants'; import { queryInputMixin, @@ -19,7 +23,7 @@ import { } from '../pipe'; const Bindings: BindingsConfig = { - getAll: { + [METHOD_NAME.getAll]: { method: RequestMethod.GET, name: 'getAll', path: '/', @@ -36,7 +40,7 @@ const Bindings: BindingsConfig = { }, ], }, - getOne: { + [METHOD_NAME.getOne]: { method: RequestMethod.GET, name: 'getOne', path: `:${PARAMS_RESOURCE_ID}`, @@ -58,7 +62,7 @@ const Bindings: BindingsConfig = { }, ], }, - deleteOne: { + [METHOD_NAME.deleteOne]: { method: RequestMethod.DELETE, name: 'deleteOne', path: `:${PARAMS_RESOURCE_ID}`, @@ -71,7 +75,7 @@ const Bindings: BindingsConfig = { }, ], }, - postOne: { + [METHOD_NAME.postOne]: { method: RequestMethod.POST, name: 'postOne', path: '/', @@ -83,7 +87,7 @@ const Bindings: BindingsConfig = { }, ], }, - patchOne: { + [METHOD_NAME.patchOne]: { method: RequestMethod.PATCH, name: 'patchOne', path: `:${PARAMS_RESOURCE_ID}`, @@ -100,7 +104,7 @@ const Bindings: BindingsConfig = { }, ], }, - getRelationship: { + [METHOD_NAME.getRelationship]: { path: `:${PARAMS_RESOURCE_ID}/relationships/:${PARAMS_RELATION_NAME}`, name: 'getRelationship', method: RequestMethod.GET, @@ -118,7 +122,7 @@ const Bindings: BindingsConfig = { }, ], }, - postRelationship: { + [METHOD_NAME.postRelationship]: { path: `:${PARAMS_RESOURCE_ID}/relationships/:${PARAMS_RELATION_NAME}`, name: 'postRelationship', method: RequestMethod.POST, @@ -140,7 +144,7 @@ const Bindings: BindingsConfig = { }, ], }, - deleteRelationship: { + [METHOD_NAME.deleteRelationship]: { path: `:${PARAMS_RESOURCE_ID}/relationships/:${PARAMS_RELATION_NAME}`, name: 'deleteRelationship', method: RequestMethod.DELETE, @@ -162,7 +166,7 @@ const Bindings: BindingsConfig = { }, ], }, - patchRelationship: { + [METHOD_NAME.patchRelationship]: { path: `:${PARAMS_RESOURCE_ID}/relationships/:${PARAMS_RELATION_NAME}`, name: 'patchRelationship', method: RequestMethod.PATCH, diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/bind-controller.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/bind-controller.ts index 282dabf9..4b14ba9d 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/bind-controller.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/bind-controller.ts @@ -41,12 +41,22 @@ export function bindController( if (!Object.prototype.hasOwnProperty.call(controller.prototype, name)) { // need uniq descriptor for correct work swagger + + const func = function (this: typeof controller.prototype, + ...arg: Parameters + ): ReturnType { + return this.constructor.__proto__.prototype[name].call(this, ...arg); + }; + + Object.defineProperty(func, 'name', { + value: name, + writable: false, + enumerable: false, + configurable: true + }); + Reflect.defineProperty(controller.prototype, name, { - value: function ( - ...arg: Parameters - ): ReturnType { - return this.constructor.__proto__.prototype[name].call(this, ...arg); - }, + value: func, writable: true, enumerable: false, configurable: true, diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/create-controller.spec.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/create-controller.spec.ts index 1940b9ac..f5938e4c 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/create-controller.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/create-controller.spec.ts @@ -8,6 +8,7 @@ import { createController } from './create-controller'; import { JsonBaseController } from '../controllers/json-base.controller'; import { JSON_API_CONTROLLER_POSTFIX, + JSON_API_DECORATOR_ENTITY, ORM_SERVICE, ORM_SERVICE_PROPS, } from '../../../constants'; @@ -52,12 +53,15 @@ describe('createController', () => { expect(Reflect.getMetadata(CONTROLLER_WATERMARK, result)).toBe(true); expect(Reflect.getMetadata(PATH_METADATA, result)).toBe('users'); + expect(Reflect.getMetadata(JSON_API_DECORATOR_ENTITY, result)).toBe(Users); expect(Reflect.getMetadata(CONTROLLER_WATERMARK, result2)).toBe(true); expect(Reflect.getMetadata(PATH_METADATA, result2)).toBe('users'); + expect(Reflect.getMetadata(JSON_API_DECORATOR_ENTITY, result)).toBe(Users); expect(Reflect.getMetadata(CONTROLLER_WATERMARK, result3)).toBe(true); expect(Reflect.getMetadata(PATH_METADATA, result3)).toBe(overrideRoute); + expect(Reflect.getMetadata(JSON_API_DECORATOR_ENTITY, result)).toBe(Users); }); it('Check inject typeorm, service', () => { diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/create-controller.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/create-controller.ts index ed7558f2..de193ab3 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/create-controller.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/helpers/create-controller.ts @@ -2,13 +2,14 @@ import { Controller, Inject, UseInterceptors } from '@nestjs/common'; import { kebabCase } from 'change-case-commonjs'; import { ModuleMixinOptions, NestController } from '../../../types'; import { DecoratorOptions } from '../types'; -import { getProviderName, nameIt } from './utils'; +import { entityForClass, getProviderName, nameIt } from './utils'; import { JsonBaseController } from '../controllers'; import { JSON_API_DECORATOR_OPTIONS, ORM_SERVICE_PROPS, ORM_SERVICE, JSON_API_CONTROLLER_POSTFIX, + JSON_API_DECORATOR_ENTITY, } from '../../../constants'; import { ErrorInterceptors, LogTimeInterceptors } from '../interceptors'; import { getEntityName } from '@klerick/json-api-nestjs-shared'; @@ -24,6 +25,9 @@ export function createController( JsonBaseController ); + if(!entityForClass(controllerClass)){ + Reflect.defineMetadata(JSON_API_DECORATOR_ENTITY, entity, controllerClass) + } const entityName = getEntityName(entity); if ( diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/mixin.module.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/mixin.module.ts index 13aadcb9..a10df5ea 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/mixin.module.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/mixin.module.ts @@ -36,6 +36,8 @@ export class MixinModule { bindController(controllerClass, entity, moduleConfig); + mixinOptions.config.hooks.afterCreateController(controllerClass) + const optionProvider = { provide: CONTROLLER_OPTIONS_TOKEN, useValue: moduleConfig, diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/pipe/post-input/post-input.pipe.spec.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/pipe/post-input/post-input.pipe.spec.ts index c8b43a25..8c196736 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/pipe/post-input/post-input.pipe.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/pipe/post-input/post-input.pipe.spec.ts @@ -7,14 +7,17 @@ import { import { PostInputPipe } from './post-input.pipe'; import { PostData, ZodPost } from '../../zod'; -import { JSONValue } from '../../types'; -import { ZOD_POST_SCHEMA } from '../../../../constants'; +import { EntityControllerParam, JSONValue } from '../../types'; +import { + CONTROLLER_OPTIONS_TOKEN, + ZOD_POST_SCHEMA, +} from '../../../../constants'; import { Users } from '../../../../utils/___test___/test-classes.helper'; describe('PostInputPipe', () => { let pipe: PostInputPipe; let mockSchema: ZodPost; - + let controllerOptions: EntityControllerParam; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -25,17 +28,24 @@ describe('PostInputPipe', () => { parse: vi.fn(), }, }, + { + provide: CONTROLLER_OPTIONS_TOKEN, + useValue: { + allowSetId: false, + }, + } ], }).compile(); pipe = module.get>(PostInputPipe); mockSchema = module.get>(ZOD_POST_SCHEMA); + controllerOptions = module.get(CONTROLLER_OPTIONS_TOKEN); }); it('should transform JSONValue to PostData on success', () => { const input: JSONValue = { key: 'value' } as any; const expectedData: PostData = { id: 1, key: 'value' } as any; - + controllerOptions.allowSetId = false; vi .spyOn(mockSchema, 'parse') .mockReturnValue({ data: expectedData } as any); @@ -44,6 +54,18 @@ describe('PostInputPipe', () => { expect(mockSchema.parse).toHaveBeenCalledWith(input); }); + it('should transform JSONValue to PostData on success and delete id', () => { + const input: JSONValue = { key: 'value' } as any; + const expectedData: PostData = { id: 1, key: 'value' } as any; + + vi + .spyOn(mockSchema, 'parse') + .mockReturnValue({ data: expectedData } as any); + + expect(pipe.transform(input)).toEqual({key: 'value'}); + expect(mockSchema.parse).toHaveBeenCalledWith(input); + }); + it('should throw BadRequestException if ZodError occurs', () => { const input: JSONValue = { key: 'value' }; diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/pipe/post-input/post-input.pipe.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/pipe/post-input/post-input.pipe.ts index b7867bce..ff11fb20 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/pipe/post-input/post-input.pipe.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/pipe/post-input/post-input.pipe.ts @@ -7,16 +7,24 @@ import { import { ZodError } from 'zod'; import { PostData, ZodPost } from '../../zod'; -import { ZOD_POST_SCHEMA } from '../../../../constants'; -import { JSONValue } from '../../types'; +import { + CONTROLLER_OPTIONS_TOKEN, + ZOD_POST_SCHEMA, +} from '../../../../constants'; +import { EntityControllerParam, JSONValue } from '../../types'; export class PostInputPipe implements PipeTransform> { @Inject(ZOD_POST_SCHEMA) private zodInputPostSchema!: ZodPost; + @Inject(CONTROLLER_OPTIONS_TOKEN) private controllerParams!: EntityControllerParam; transform(value: JSONValue): PostData { try { - return this.zodInputPostSchema.parse(value)['data'] as PostData; + const result = this.zodInputPostSchema.parse(value)['data'] as PostData; + if (!this.controllerParams.allowSetId) { + delete result.id; + } + return result; } catch (e) { if (e instanceof ZodError) { throw new BadRequestException(e.issues); diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/service/json-api-transformer.service.spec.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/service/json-api-transformer.service.spec.ts index 74b11cf4..c408ab04 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/service/json-api-transformer.service.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/service/json-api-transformer.service.spec.ts @@ -55,7 +55,7 @@ describe('JsonApiTransformerService - extractAttributes', () => { userObject.firstName = faker.person.firstName(); userObject.lastName = faker.person.lastName(); userObject.isActive = null; - userObject.login = faker.internet.userName({ + userObject.login = faker.internet.username({ lastName: userObject.lastName, firstName: userObject.firstName, }); @@ -331,7 +331,7 @@ describe('JsonApiTransformerService - extractAttributes', () => { userObject.manager = { id: 1, - login: faker.internet.userName({ + login: faker.internet.username({ lastName: faker.person.lastName(), firstName: faker.person.firstName(), }), diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/swagger/method/index.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/swagger/method/index.ts index 404cbe19..a11dca44 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/swagger/method/index.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/swagger/method/index.ts @@ -8,8 +8,6 @@ import { deleteRelationship } from './delete-relationship'; import { postRelationship } from './post-relationship'; import { patchRelationship } from './patch-relationship'; -import { OrmService } from '../../types'; - export const swaggerMethod = { getAll, getOne, @@ -22,6 +20,3 @@ export const swaggerMethod = { patchRelationship, } as const; -export type SwaggerMethod = { - [Key in keyof OrmService]?: (typeof swaggerMethod)[Key]; -}; diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/types/decorator-options.type.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/types/decorator-options.type.ts index ea545b87..da1985be 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/types/decorator-options.type.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/types/decorator-options.type.ts @@ -5,6 +5,7 @@ import { PrepareParams } from '../../../types'; type ControllerOptions = { allowMethod: Array; overrideRoute: string; + allowSetId: boolean }; export type DecoratorOptions> = Partial< diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/types/orm-service.type.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/types/orm-service.type.ts index 21ee0f36..d76bf649 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/types/orm-service.type.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/types/orm-service.type.ts @@ -15,12 +15,23 @@ import { export interface OrmService { getAll( - query: Query + query: Query, ): Promise>; + getAll( + query: Query, + transformData?: boolean, + additionalQueryParams?: Record + ): Promise | { totalItems: number; items: E[] }>; getOne( id: number | string, query: QueryOne ): Promise>; + getOne( + id: number | string, + query: QueryOne, + transformData?: boolean, + additionalQueryParams?: Record + ): Promise | E>; deleteOne(id: number | string): Promise; patchOne( id: number | string, @@ -51,4 +62,10 @@ export interface OrmService { rel: Rel, input: PatchRelationshipData ): Promise>; + + loadRelations( + relationships: PatchData['relationships'] | PostData['relationships'], + ): Promise<{ + [K in RelationKeys]: E[K]; + }>; } diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/zod/zod-query-schema/filter.spec.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/zod/zod-query-schema/filter.spec.ts index b6df96b6..7d21a5c8 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/zod/zod-query-schema/filter.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/zod/zod-query-schema/filter.spec.ts @@ -20,7 +20,17 @@ describe('Check "filter" zod schema', () => { relation: null, }; const result = schema.parse(check1); - expect(result).toEqual(check1); + + expect(result).toEqual({ + ...check1, + target: { + ...check1.target, + id: { + gte: 1213, + ne: 12, + }, + }, + }); }); it('Valid schema - check2', () => { @@ -42,7 +52,15 @@ describe('Check "filter" zod schema', () => { }, }; const result = schema.parse(check2); - expect(result).toEqual(check2); + expect(result).toEqual({ + ...check2, + target: { + ...check2.target, + id: { + gte: 1213, + } + } + }); }); it('Valid schema - check3', () => { @@ -71,7 +89,17 @@ describe('Check "filter" zod schema', () => { }, }; const result = schema.parse(check4); - expect(result).toEqual(check4); + expect(result).toEqual({ + ...check4, + relation: { + ...check4.relation, + comments: { + id: { + lte: 123, + }, + } + } + }); }); it('Valid schema - check5', () => { @@ -108,7 +136,16 @@ describe('Check "filter" zod schema', () => { relation: null, }; const result = schema.parse(check6); - expect(result).toEqual(check6); + expect(result).toEqual({ + ...check6, + target: { + ...check6.target, + id:{ + gte: 1213, + ne: 123, + } + }, + }); }); it('Valid schema - check7', () => { @@ -121,7 +158,12 @@ describe('Check "filter" zod schema', () => { relation: null, }; const result = schema.parse(check7); - expect(result).toEqual(check7); + expect(result).toEqual({ + ...check7, + target: { + ...check7.target, + }, + }); }); it('Valid schema - check8', () => { @@ -134,7 +176,15 @@ describe('Check "filter" zod schema', () => { relation: null, }; const result = schema.parse(check8); - expect(result).toEqual(check8); + expect(result).toEqual({ + ...check8, + target: { + ...check8.target, + createdAt: { + eq: new Date('2023-12-08T09:40:58.020Z'), + }, + }, + }); }); it('Valid schema - check9', () => { @@ -170,6 +220,10 @@ describe('Check "filter" zod schema', () => { ...check, target: { ...check.target, + id: { + gte: 1213, + ne: 123, + }, addresses: { eq: 'null', }, diff --git a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/zod/zod-query-schema/filter.ts b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/zod/zod-query-schema/filter.ts index 86d4ae30..895e1f3e 100644 --- a/libs/json-api/json-api-nestjs/src/lib/modules/mixin/zod/zod-query-schema/filter.ts +++ b/libs/json-api/json-api-nestjs/src/lib/modules/mixin/zod/zod-query-schema/filter.ts @@ -61,6 +61,22 @@ function getZodRulesForField(type: TypeField = TypeField.string) { .refine(stringMustBe(type), { error: `String should be as ${type}`, }) + .transform((r) => { + if (r === 'null' || r === null) { + return r; + } + + switch (type) { + case TypeField.boolean: + return Boolean(r); + case TypeField.number: + return Number(r); + case TypeField.date: + return new Date(r); + } + + return r; + }) .optional(), }), {} as { @@ -73,7 +89,7 @@ function getZodRulesForField(type: TypeField = TypeField.string) { ...acum, [val]: zodRuleStringArray .refine(elementOfArrayMustBe(type), { - error: `String should be as ${type}`, + error: `String element of array should be as ${type}`, }) .optional(), }), diff --git a/libs/json-api/json-api-nestjs/src/lib/types/module-options.types.ts b/libs/json-api/json-api-nestjs/src/lib/types/module-options.types.ts index c9e5e448..76aa2938 100644 --- a/libs/json-api/json-api-nestjs/src/lib/types/module-options.types.ts +++ b/libs/json-api/json-api-nestjs/src/lib/types/module-options.types.ts @@ -1,4 +1,4 @@ -import { DynamicModule } from '@nestjs/common'; +import { DynamicModule, Type } from '@nestjs/common'; import { NonEmptyArray } from 'zod-validation-error'; import { AnyEntity, EntityClass } from '@klerick/json-api-nestjs-shared'; import { @@ -15,6 +15,9 @@ type ModuleCommonParams = { controllers?: NestController; providers?: NestProvider; imports?: NestImport; + hooks?: { + afterCreateController: (controller: Type) => void; + } }; type ModuleCommonOptions = { @@ -22,6 +25,7 @@ type ModuleCommonOptions = { debug?: boolean; pipeForId?: PipeMixin; operationUrl?: string; + allowSetId?: boolean }; type ModuleOptionsParams> = IfEquals< diff --git a/libs/json-api/json-api-nestjs/src/lib/utils/module-helper.spec.ts b/libs/json-api/json-api-nestjs/src/lib/utils/module-helper.spec.ts index e378638e..927c4974 100644 --- a/libs/json-api/json-api-nestjs/src/lib/utils/module-helper.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/utils/module-helper.spec.ts @@ -79,10 +79,14 @@ describe('module-helper', () => { providers: [], controllers: [], entities: [], + hooks: { + afterCreateController: result.hooks.afterCreateController, + }, options: { operationUrl: undefined, requiredSelectField: false, debug: false, + allowSetId: false, pipeForId: ParseIntPipe, }, }); @@ -115,6 +119,7 @@ describe('module-helper', () => { const result = prepareConfig(moduleParams); expect(result.options).toEqual({ + allowSetId: false, operationUrl: 'http://example.com', requiredSelectField: false, debug: true, @@ -179,6 +184,7 @@ describe('module-helper', () => { expect(result.entities).toBe(entities); expect(result).toHaveProperty('options'); expect(result.options).toEqual({ + allowSetId: false, operationUrl: undefined, requiredSelectField: false, debug: false, diff --git a/libs/json-api/json-api-nestjs/src/lib/utils/module-helper.ts b/libs/json-api/json-api-nestjs/src/lib/utils/module-helper.ts index 6649d37e..e37d5c7a 100644 --- a/libs/json-api/json-api-nestjs/src/lib/utils/module-helper.ts +++ b/libs/json-api/json-api-nestjs/src/lib/utils/module-helper.ts @@ -29,7 +29,11 @@ export function prepareConfig( requiredSelectField: !!options['requiredSelectField'], debug: !!options['debug'], pipeForId: options['pipeForId'] || ParseIntPipe, + allowSetId: !!options['allowSetId'] }, + hooks: { + afterCreateController: moduleParams['hooks'] && moduleParams['hooks']['afterCreateController'] || (() => void 0) + } }; } diff --git a/libs/json-api/json-api-nestjs/vite.config.mts b/libs/json-api/json-api-nestjs/vite.config.mts index 299f1d36..f9f62e66 100644 --- a/libs/json-api/json-api-nestjs/vite.config.mts +++ b/libs/json-api/json-api-nestjs/vite.config.mts @@ -37,7 +37,7 @@ export default defineConfig(() => ({ reporters: ['default'], coverage: { enabled: true, - reporter: ['json'], + reporter: ['json-summary'], reportsDirectory: '../../../coverage/json-api-nestjs', provider: 'v8' as const, }, diff --git a/libs/json-rpc/nestjs-json-rpc-sdk/vite.config.mts b/libs/json-rpc/nestjs-json-rpc-sdk/vite.config.mts index a4c39565..4e94cc19 100644 --- a/libs/json-rpc/nestjs-json-rpc-sdk/vite.config.mts +++ b/libs/json-rpc/nestjs-json-rpc-sdk/vite.config.mts @@ -37,7 +37,7 @@ export default defineConfig(() => ({ reporters: ['default'], coverage: { enabled: true, - reporter: ['json'], + reporter: ['json-summary'], reportsDirectory: '../../../coverage/nestjs-json-rpc-sdk', provider: 'v8' as const, }, diff --git a/libs/json-rpc/nestjs-json-rpc/vite.config.mts b/libs/json-rpc/nestjs-json-rpc/vite.config.mts index 78b0989a..fc88ee66 100644 --- a/libs/json-rpc/nestjs-json-rpc/vite.config.mts +++ b/libs/json-rpc/nestjs-json-rpc/vite.config.mts @@ -37,7 +37,7 @@ export default defineConfig(() => ({ reporters: ['default'], coverage: { enabled: true, - reporter: ['json'], + reporter: ['json-summary'], reportsDirectory: '../../../coverage/nestjs-json-rpc', provider: 'v8' as const, }, diff --git a/libs/microorm-database/src/lib/config-cli.ts b/libs/microorm-database/src/lib/config-cli.ts index 20385027..c54b2272 100644 --- a/libs/microorm-database/src/lib/config-cli.ts +++ b/libs/microorm-database/src/lib/config-cli.ts @@ -1,34 +1,11 @@ -import { Options as PgOptions, PostgreSqlDriver } from '@mikro-orm/postgresql'; -import { Options as MyOptions, MySqlDriver } from '@mikro-orm/mysql'; -import { TSMigrationGenerator } from '@mikro-orm/migrations'; import { Options } from '@mikro-orm/core'; -import { join } from 'path'; - -import * as allEntities from './entities'; - -const entitiesArray = Object.values(allEntities).filter( - (maybeClass) => typeof maybeClass === 'function' -); - -const mySqlOptions: MyOptions = { - driver: MySqlDriver, -}; - -const pgSqlOptions: PgOptions = { - driver: PostgreSqlDriver, -}; +import { TSMigrationGenerator } from '@mikro-orm/migrations'; +import { join } from 'node:path'; +import { pgConfig } from './config-pg'; +import { PGlite } from '@electric-sql/pglite'; const config: Options = { - dbName: process.env['DB_NAME'], - // dbName: 'microorm-test', - host: process.env['DB_HOST'], - port: parseInt(`${process.env['DB_PORT']}`, 10), - user: process.env['DB_USERNAME'], - password: process.env['DB_PASSWORD'], - entitiesTs: [join(__dirname, '/entities/**/*')], - entities: entitiesArray, - debug: process.env['DB_LOGGING'] === '1', - ...(process.env['DB_TYPE'] === 'mysql' ? mySqlOptions : pgSqlOptions), + ...pgConfig, migrations: { tableName: 'migrations', path: join(__dirname, '/migrations'), @@ -40,6 +17,21 @@ const config: Options = { emit: 'ts', generator: TSMigrationGenerator, }, + seeder: { + path: join(__dirname, './seeders'), + } }; -export default config; +export default Promise.resolve(config).then(async (configR) => { + + // @ts-ignore + const {driverOptions: {connection: {pglite: pgLiteCall}}} = configR; + const pgLite: PGlite = pgLiteCall(); + await pgLite.waitReady + + // not parser array + // pgLite.parsers[1003] = (...arg: any[]) => arg[0] + + + return config +}); diff --git a/libs/microorm-database/src/lib/config-pg/index.ts b/libs/microorm-database/src/lib/config-pg/index.ts new file mode 100644 index 00000000..cc98c0ed --- /dev/null +++ b/libs/microorm-database/src/lib/config-pg/index.ts @@ -0,0 +1 @@ +export * from './pg-config' diff --git a/libs/microorm-database/src/lib/config-pg/pg-config.ts b/libs/microorm-database/src/lib/config-pg/pg-config.ts new file mode 100644 index 00000000..1880718a --- /dev/null +++ b/libs/microorm-database/src/lib/config-pg/pg-config.ts @@ -0,0 +1,39 @@ +import * as process from 'node:process'; +import { mkdirSync } from 'node:fs'; +// @ts-ignore +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp'; +import { Options } from '@mikro-orm/core'; +import { SqlHighlighter } from '@mikro-orm/sql-highlighter'; + +import { PGlite } from '@electric-sql/pglite'; +import { PGliteDriver, PGliteConnectionConfig } from "mikro-orm-pglite"; +import * as allEntities from '../entities'; + +const pgDir = process.env['TEST'] ? './tmp/pg-test/microorm' : './tmp/pg/microorm' + +mkdirSync(pgDir, { recursive: true }); + +const entitiesArray = Object.values(allEntities).filter( + (maybeClass) => typeof maybeClass === 'function' +); +const testDbName = 'mikroorm-database' +const pgLite = new PGlite({ + extensions: { uuid_ossp }, + dataDir: pgDir +}) + + +const pgConfig: Options = { + highlighter: new SqlHighlighter(), + driver: PGliteDriver, + dbName: testDbName, + driverOptions: { + connection: { + pglite: () => pgLite, + } satisfies PGliteConnectionConfig, + }, + entities: entitiesArray, + debug: process.env['DB_LOGGING'] === '1' +}; + +export { pgConfig }; diff --git a/libs/microorm-database/src/lib/config.ts b/libs/microorm-database/src/lib/config.ts index fa30ac00..23060df8 100644 --- a/libs/microorm-database/src/lib/config.ts +++ b/libs/microorm-database/src/lib/config.ts @@ -1,13 +1,15 @@ -import { Options } from '@mikro-orm/core'; +import { pgConfig } from './config-pg'; +import { ClsServiceManager } from 'nestjs-cls'; +import { EntityManager } from '@mikro-orm/core'; +import { DatabaseLoggerService } from './database-logger.service'; +import type { LoggerOptions } from '@mikro-orm/core'; +export const CONTEXT_STORE_NAME = Symbol('mikroorm-database'); -import ormConfig from './config-cli'; - -const { entitiesTs, ...configOther } = ormConfig; - -export const config: Options = { - discovery: { requireEntitiesArray: true }, +export const config = { + ...pgConfig, contextName: 'default', + loggerFactory: (options: LoggerOptions) => new DatabaseLoggerService(options), + context: () => ClsServiceManager.getClsService().get(CONTEXT_STORE_NAME), // @ts-ignore - registerRequestContext: false, - ...configOther, + registerRequestContext: false }; diff --git a/libs/microorm-database/src/lib/database-logger.service.ts b/libs/microorm-database/src/lib/database-logger.service.ts new file mode 100644 index 00000000..f0ca7e78 --- /dev/null +++ b/libs/microorm-database/src/lib/database-logger.service.ts @@ -0,0 +1,98 @@ +import { Logger as NestJsLogger } from '@nestjs/common'; + +import type { LoggerOptions } from '@mikro-orm/core'; +import type { Highlighter } from '@mikro-orm/core'; +import type { LogContext, LoggerNamespace } from '@mikro-orm/postgresql'; +import { DefaultLogger } from '@mikro-orm/postgresql'; + +export type AnyString = string & {}; + +export class DatabaseLoggerService extends DefaultLogger { + private static logger = new NestJsLogger('MikroOrmDatabaseModule'); + private useHighlighter: Highlighter | undefined = undefined; + private hasReplicas: boolean | undefined = undefined; + + constructor(options: LoggerOptions) { + super(options); + this.useHighlighter = options.highlighter; + this.hasReplicas = options.usesReplicas; + } + + override log( + namespace: LoggerNamespace | AnyString, + message: string, + context?: LogContext, + ): void { + if (namespace === 'info' && !this.useHighlighter) { + message = message.replace( + // eslint-disable-next-line no-control-regex + /\x1b\[[0-9;]*m/g, + '', + ); + } + + message = message.replace(/\n/g, '').replace(/ +/g, ' ').trim(); + + if (context && 'contextName' in context) { + namespace = `${namespace}:${context['contextName']}`; + } + const messageResult = `(${namespace}) ${message}`; + let typeLog: 'debug' | 'error' | 'warn' = 'debug'; + switch (context?.level) { + case 'error': + typeLog = 'debug'; + break; + case 'warning': + typeLog = 'warn'; + break; + } + + DatabaseLoggerService.logger[typeLog](messageResult); + } + + override logQuery(context: { query: string } & LogContext): void { + if (this.useHighlighter) { + return super.logQuery(context); + } + + let msg = context.query; + + if (context.took != null) { + const meta = [`took ${context.took} ms`]; + + if (context.results != null) { + meta.push( + `${context.results} result${ + context.results === 0 || context.results > 1 ? 's' : '' + }`, + ); + } + + if (context.affected != null) { + meta.push( + `${context.affected} row${ + context.affected === 0 || context.affected > 1 ? 's' : '' + } affected`, + ); + } + + msg += ` [${meta.join(', ')}]`; + } + + if (this.hasReplicas && context.connection) { + msg += ` (via ${context.connection.type} connection '${context.connection.name}')`; + } + + return this.log('query', msg, context); + } + + // setDebugMode(debugMode: boolean | LoggerNamespace[]): void { + // console.log('setDebugMode', debugMode); + // // throw new Error('Method not implemented.'); + // } + // isEnabled(namespace: LoggerNamespace, context?: LogContext): boolean { + // console.log('isEnabled', namespace, context); + // // throw new Error('Method not implemented.'); + // return true; + // } +} diff --git a/libs/microorm-database/src/lib/entities/acl-test/article.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/article.entity.ts new file mode 100644 index 00000000..19e13d05 --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/article.entity.ts @@ -0,0 +1,173 @@ +import { + Entity, + PrimaryKey, + Property, + ManyToOne, + Enum, + ArrayType, +} from '@mikro-orm/core'; +import { UsersAcl, IUsersAcl } from './user.entity'; + +export enum ArticleStatus { + DRAFT = 'draft', + REVIEW = 'review', + PUBLISHED = 'published', +} + +export enum ArticleVisibility { + PUBLIC = 'public', + PRIVATE = 'private', + UNLISTED = 'unlisted', +} + +export interface ArticleMetadata { + readTime: number; + featured: boolean; + premium: boolean; +} + +export type IArticleAcl = ArticleAcl; + +/** + * Article entity for ACL testing - Complex scenarios + * + * ACL Test Cases: + * - Multiple owners (authorId OR coAuthorIds.includes(userId)) + * - Array conditions (checking if userId in coAuthorIds array) + * - Nested object conditions (metadata.premium) + * - Time-based access (expiresAt > now) + * - Complex workflows (draft -> review -> published) + * - Editor role (separate from author) + * - Template: ${@input.coAuthorIds}, ${metadata.premium}, ${currentTime} + */ +@Entity({ + tableName: 'acl_articles', +}) +export class ArticleAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + + @Property({ + type: 'varchar', + length: 255, + nullable: false, + }) + public title!: string; + + @Property({ + type: 'text', + nullable: false, + }) + public content!: string; + + /** + * Primary author (ownership) + */ + @ManyToOne(() => UsersAcl, { + nullable: false, + fieldName: 'author_id', + }) + public author!: IUsersAcl; + + /** + * Co-authors array for multiple ownership testing + * ACL: Check if currentUserId in this array + */ + @Property({ + name: 'co_author_ids', + type: ArrayType, + columnType: 'integer[]', + default: [], + }) + public coAuthorIds!: number[]; + + /** + * Editor (different from author/co-authors) + * Can edit but not delete + */ + @ManyToOne(() => UsersAcl, { + nullable: true, + fieldName: 'editor_id', + }) + public editor!: IUsersAcl | null; + + /** + * Workflow status + */ + @Enum(() => ArticleStatus) + @Property({ + type: 'varchar', + length: 20, + default: ArticleStatus.DRAFT, + }) + public status!: ArticleStatus; + + /** + * Visibility control + */ + @Enum(() => ArticleVisibility) + @Property({ + type: 'varchar', + length: 20, + default: ArticleVisibility.PUBLIC, + }) + public visibility!: ArticleVisibility; + + /** + * Metadata as JSON object + * ACL: Check nested properties like metadata.premium + */ + @Property({ + type: 'json', + nullable: false, + default: '{"readTime": 0, "featured": false, "premium": false}', + }) + public metadata!: ArticleMetadata; + + /** + * Publish date for time-based access + */ + @Property({ + length: 0, + name: 'published_at', + nullable: true, + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + public publishedAt!: Date | null; + + /** + * Expiration date for temporary access + * ACL: Check if current time < expiresAt + */ + @Property({ + length: 0, + name: 'expires_at', + nullable: true, + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + public expiresAt!: Date | null; + + @Property({ + length: 0, + name: 'created_at', + nullable: false, + defaultRaw: 'CURRENT_TIMESTAMP(0)', + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + createdAt: Date = new Date(); + + @Property({ + length: 0, + onUpdate: () => new Date(), + name: 'updated_at', + nullable: false, + columnType: 'timestamp(0) without time zone', + defaultRaw: 'CURRENT_TIMESTAMP(0)', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/microorm-database/src/lib/entities/acl-test/category.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/category.entity.ts new file mode 100644 index 00000000..468e752b --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/category.entity.ts @@ -0,0 +1,107 @@ +import { + Entity, + PrimaryKey, + Property, + ManyToOne, + OneToMany, + Collection, +} from '@mikro-orm/core'; + +export type ICategoryAcl = CategoryAcl; + +/** + * Category entity for ACL testing + * Self-referencing hierarchical structure + * + * ACL Test Cases: + * - Hierarchical permissions (parent -> children access) + * - Self-referencing relationships + * - Depth-based access control (level) + * - Active/inactive categories + * - Template: ${@input.parentId}, ${category.parent.id} + */ +@Entity({ + tableName: 'acl_categories', +}) +export class CategoryAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + + @Property({ + type: 'varchar', + length: 100, + nullable: false, + }) + public name!: string; + + @Property({ + type: 'varchar', + length: 150, + nullable: false, + unique: true, + }) + public slug!: string; + + /** + * Self-referencing parent category + */ + @ManyToOne(() => CategoryAcl, { + nullable: true, + fieldName: 'parent_id', + }) + public parent!: CategoryAcl | null; + + /** + * Self-referencing children categories + */ + @OneToMany(() => CategoryAcl, (category) => category.parent) + public children = new Collection(this); + + /** + * Depth level in hierarchy + * 0 = root category, 1 = first level child, etc. + */ + @Property({ + type: 'integer', + default: 0, + }) + public level!: number; + + /** + * Active/inactive flag for access control + */ + @Property({ + name: 'is_active', + type: 'boolean', + default: true, + }) + public isActive!: boolean; + + @Property({ + type: 'text', + nullable: true, + }) + public description!: string | null; + + @Property({ + length: 0, + name: 'created_at', + nullable: false, + defaultRaw: 'CURRENT_TIMESTAMP(0)', + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + createdAt: Date = new Date(); + + @Property({ + length: 0, + onUpdate: () => new Date(), + name: 'updated_at', + nullable: false, + columnType: 'timestamp(0) without time zone', + defaultRaw: 'CURRENT_TIMESTAMP(0)', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/microorm-database/src/lib/entities/acl-test/comment.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/comment.entity.ts new file mode 100644 index 00000000..41a46065 --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/comment.entity.ts @@ -0,0 +1,94 @@ +import { + Entity, + PrimaryKey, + Property, + ManyToOne, +} from '@mikro-orm/core'; +import { UsersAcl, IUsersAcl } from './user.entity'; +import { PostAcl } from './post.entity'; + +export type ICommentAcl = CommentAcl; + +/** + * Comment entity for ACL testing + * Many-to-One with Post and Users + * + * ACL Test Cases: + * - Nested ownership (comment.authorId === currentUserId) + * - Moderation (isApproved - only moderator/admin can approve) + * - Relationship chain (User -> Comment -> Post) + * - Template: ${@input.postId}, ${post.authorId} + */ +@Entity({ + tableName: 'acl_comments', +}) +export class CommentAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + + /** + * Post that this comment belongs to + */ + @ManyToOne(() => PostAcl, { + nullable: false, + fieldName: 'post_id', + }) + public post!: PostAcl; + + /** + * Author of the comment (ownership) + */ + @ManyToOne(() => UsersAcl, { + nullable: false, + fieldName: 'author_id', + }) + public author!: IUsersAcl; + + @Property({ + type: 'text', + nullable: false, + }) + public content!: string; + + /** + * Moderation flag - only moderator/admin can approve + */ + @Property({ + name: 'is_approved', + type: 'boolean', + default: false, + }) + public isApproved!: boolean; + + /** + * Edit flag to track if comment was modified + */ + @Property({ + name: 'is_edited', + type: 'boolean', + default: false, + }) + public isEdited!: boolean; + + @Property({ + length: 0, + name: 'created_at', + nullable: false, + defaultRaw: 'CURRENT_TIMESTAMP(0)', + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + createdAt: Date = new Date(); + + @Property({ + length: 0, + onUpdate: () => new Date(), + name: 'updated_at', + nullable: false, + columnType: 'timestamp(0) without time zone', + defaultRaw: 'CURRENT_TIMESTAMP(0)', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/microorm-database/src/lib/entities/acl-test/context.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/context.entity.ts new file mode 100644 index 00000000..bd502131 --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/context.entity.ts @@ -0,0 +1,30 @@ +import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; + +export type IContextTestAcl = ContextTestAcl + +@Entity({ + tableName: 'acl_context_test', +}) +export class ContextTestAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + @Property({ + type: 'json', + nullable: false, + default: '{}', + }) + aclRules!: {rules: Record[]} + + @Property({ + type: 'json', + nullable: false, + default: '{}', + }) + context!: Record +} + + + + diff --git a/libs/microorm-database/src/lib/entities/acl-test/document.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/document.entity.ts new file mode 100644 index 00000000..8d501226 --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/document.entity.ts @@ -0,0 +1,116 @@ +import { + Entity, + PrimaryKey, + Property, + ManyToOne, + ArrayType, +} from '@mikro-orm/core'; +import { UsersAcl, IUsersAcl } from './user.entity'; + +export type IDocumentAcl = DocumentAcl; + +/** + * Document entity for ACL testing - File upload scenarios + * + * ACL Test Cases: + * - Ownership (ownerId === currentUserId) + * - Shared access (sharedWith.includes(currentUserId)) + * - Public/private toggle (isPublic) + * - File-specific permissions (read, download, delete) + * - Template: ${@input.sharedWith}, ${currentUserId} + */ +@Entity({ + tableName: 'acl_documents', +}) +export class DocumentAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + + @Property({ + type: 'varchar', + length: 255, + nullable: false, + }) + public filename!: string; + + @Property({ + name: 'mime_type', + type: 'varchar', + length: 100, + nullable: false, + }) + public mimeType!: string; + + /** + * File size in bytes + */ + @Property({ + type: 'bigint', + nullable: false, + }) + public size!: number; + + /** + * File path/URL + */ + @Property({ + type: 'varchar', + length: 500, + nullable: false, + }) + public path!: string; + + /** + * Owner of the document + */ + @ManyToOne(() => UsersAcl, { + nullable: false, + fieldName: 'owner_id', + }) + public owner!: IUsersAcl; + + /** + * Array of user IDs who have access to this document + * ACL: Check if currentUserId in this array + */ + @Property({ + name: 'shared_with', + type: ArrayType, + columnType: 'integer[]', + default: [], + }) + public sharedWith!: number[]; + + /** + * Public access flag + * If true, anyone can read (but not modify/delete) + */ + @Property({ + name: 'is_public', + type: 'boolean', + default: false, + }) + public isPublic!: boolean; + + @Property({ + length: 0, + name: 'uploaded_at', + nullable: false, + defaultRaw: 'CURRENT_TIMESTAMP(0)', + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + uploadedAt: Date = new Date(); + + @Property({ + length: 0, + onUpdate: () => new Date(), + name: 'updated_at', + nullable: false, + columnType: 'timestamp(0) without time zone', + defaultRaw: 'CURRENT_TIMESTAMP(0)', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/microorm-database/src/lib/entities/acl-test/index.ts b/libs/microorm-database/src/lib/entities/acl-test/index.ts new file mode 100644 index 00000000..eca3f2bd --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/index.ts @@ -0,0 +1,38 @@ +/** + * ACL Test Entities + * + * These entities are designed to test all aspects of the ACL permissions module. + * + * Entity Overview: + * - UserProfile: One-to-One with Users, field-level permissions + * - Category: Self-referencing hierarchical structure + * - Post: Core entity with status, ownership, relationships + * - Comment: Nested ownership, moderation + * - Tag: Many-to-Many with Post, creator ownership + * - Article: Complex scenarios (multiple owners, nested objects, time-based) + * - Document: File upload scenarios, shared access + * + * Coverage: + * ✅ Ownership patterns + * ✅ Role-based access + * ✅ Field-level permissions (Entity:select) + * ✅ Relationship permissions (Entity:include) + * ✅ Status-based access + * ✅ Hierarchical permissions + * ✅ Array conditions + * ✅ Nested object conditions + * ✅ Time-based access + * ✅ @context templates + * ✅ @input templates + */ + +export * from './user.entity'; +export * from './user-profile.entity'; +export * from './category.entity'; +export * from './post.entity'; +export * from './comment.entity'; +export * from './tag.entity'; +export * from './article.entity'; +export * from './document.entity'; + +export * from './context.entity' diff --git a/libs/microorm-database/src/lib/entities/acl-test/post.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/post.entity.ts new file mode 100644 index 00000000..3096ff18 --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/post.entity.ts @@ -0,0 +1,161 @@ +import { + Entity, + PrimaryKey, + Property, + ManyToOne, + OneToMany, + ManyToMany, + Collection, + Enum, +} from '@mikro-orm/core'; +import { UsersAcl, IUsersAcl } from './user.entity'; +import { CategoryAcl, ICategoryAcl } from './category.entity'; +import { TagAcl, ITagAcl } from './tag.entity'; +import { CommentAcl, ICommentAcl } from './comment.entity'; + +export enum PostStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + ARCHIVED = 'archived', +} + +export type IPostAcl = PostAcl; + +/** + * Post entity for ACL testing + * Many-to-One with Users (author) and Category + * Many-to-Many with Tag + * One-to-Many with Comment + * + * ACL Test Cases: + * - Ownership (authorId === currentUserId) + * - Status-based access (only published posts for guest) + * - Conditional access (owner OR admin OR moderator) + * - Field-level permissions (Post:select - viewCount only for author/admin) + * - Relationship permissions (Post:include - comments, tags) + * - Template: ${currentUserId}, ${@input.authorId}, ${@input.status} + */ +@Entity({ + tableName: 'acl_posts', +}) +export class PostAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + + @Property({ + type: 'varchar', + length: 255, + nullable: false, + }) + public title!: string; + + @Property({ + type: 'text', + nullable: false, + }) + public content!: string; + + @Property({ + type: 'text', + nullable: true, + }) + public excerpt!: string | null; + + /** + * Author of the post (ownership) + */ + @ManyToOne(() => UsersAcl, { + nullable: false, + fieldName: 'author_id', + }) + public author!: IUsersAcl; + + /** + * Category for hierarchical permissions + */ + @ManyToOne(() => CategoryAcl, { + nullable: true, + fieldName: 'category_id', + }) + public category!: ICategoryAcl | null; + + /** + * Status for status-based access control + */ + @Enum(() => PostStatus) + @Property({ + type: 'varchar', + length: 20, + default: PostStatus.DRAFT, + }) + public status!: PostStatus; + + /** + * Published flag (alternative to status) + */ + @Property({ + name: 'is_published', + type: 'boolean', + default: false, + }) + public isPublished!: boolean; + + /** + * Publish date for time-based access + */ + @Property({ + length: 0, + name: 'published_at', + nullable: true, + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + public publishedAt!: Date | null; + + /** + * View count - private field (only author/admin can see) + */ + @Property({ + name: 'view_count', + type: 'integer', + default: 0, + }) + public viewCount!: number; + + /** + * Many-to-Many with Tags + */ + @ManyToMany(() => TagAcl, (tag) => tag.posts, { + owner: true, + pivotTable: 'acl_posts_tags', + }) + public tags = new Collection(this); + + /** + * One-to-Many with Comments + */ + @OneToMany(() => CommentAcl, (comment) => comment.post) + public comments = new Collection(this); + + @Property({ + length: 0, + name: 'created_at', + nullable: false, + defaultRaw: 'CURRENT_TIMESTAMP(0)', + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + createdAt: Date = new Date(); + + @Property({ + length: 0, + onUpdate: () => new Date(), + name: 'updated_at', + nullable: false, + columnType: 'timestamp(0) without time zone', + defaultRaw: 'CURRENT_TIMESTAMP(0)', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/microorm-database/src/lib/entities/acl-test/tag.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/tag.entity.ts new file mode 100644 index 00000000..e2d36d29 --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/tag.entity.ts @@ -0,0 +1,99 @@ +import { + Entity, + PrimaryKey, + Property, + ManyToOne, + ManyToMany, + Collection, +} from '@mikro-orm/core'; +import { UsersAcl, IUsersAcl } from './user.entity'; +import { PostAcl } from './post.entity'; + +export type ITagAcl = TagAcl; + +/** + * Tag entity for ACL testing + * Many-to-Many relationship with Post + * + * ACL Test Cases: + * - Many-to-Many relationship (Post <-> Tag) + * - Creator ownership (createdById === currentUserId) + * - Privileged entities (isOfficial - only admin can create) + * - Template: ${currentUserId}, ${@input.createdById} + */ +@Entity({ + tableName: 'acl_tags', +}) +export class TagAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + + @Property({ + type: 'varchar', + length: 100, + nullable: false, + }) + public name!: string; + + @Property({ + type: 'varchar', + length: 150, + nullable: false, + unique: true, + }) + public slug!: string; + + /** + * User who created this tag + */ + @ManyToOne(() => UsersAcl, { + nullable: false, + fieldName: 'created_by_id', + }) + public createdBy!: IUsersAcl; + + /** + * Official tags can only be created by admins + * Regular users can create non-official tags + */ + @Property({ + name: 'is_official', + type: 'boolean', + default: false, + }) + public isOfficial!: boolean; + + @Property({ + type: 'text', + nullable: true, + }) + public description!: string | null; + + /** + * Many-to-Many relationship with Post + */ + @ManyToMany(() => PostAcl, (post) => post.tags) + public posts = new Collection(this); + + @Property({ + length: 0, + name: 'created_at', + nullable: false, + defaultRaw: 'CURRENT_TIMESTAMP(0)', + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + createdAt: Date = new Date(); + + @Property({ + length: 0, + onUpdate: () => new Date(), + name: 'updated_at', + nullable: false, + columnType: 'timestamp(0) without time zone', + defaultRaw: 'CURRENT_TIMESTAMP(0)', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/microorm-database/src/lib/entities/acl-test/user-profile.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/user-profile.entity.ts new file mode 100644 index 00000000..a8fedcc5 --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/user-profile.entity.ts @@ -0,0 +1,127 @@ +import { Entity, PrimaryKey, Property, OneToOne, Enum } from '@mikro-orm/core'; +import { UsersAcl, IUsersAcl } from './user.entity'; + + +export type IUserProfileAcl = UserProfileAcl; + +export enum UserRole { + admin = 'admin', + user = 'user', + moderator = 'moderator', +} + +/** + * UserProfile entity for ACL testing + * One-to-One relationship with Users + * + * ACL Test Cases: + * - Field-level permissions (UserProfile:select) + * - Private fields: phone, salary (only owner or admin) + * - Public fields: firstName, lastName, bio, avatar + * - Privacy settings (isPublic) + */ +@Entity({ + tableName: 'acl_user_profiles', +}) +export class UserProfileAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + + @OneToOne(() => UsersAcl, { + owner: true, + fieldName: 'user_id', + unique: true, + nullable: false, + }) + public user!: IUsersAcl; + + @Property({ + name: 'first_name', + type: 'varchar', + length: 100, + nullable: true, + }) + public firstName!: string | null; + + @Property({ + name: 'last_name', + type: 'varchar', + length: 100, + nullable: true, + }) + public lastName!: string | null; + + @Property({ + type: 'text', + nullable: true, + }) + public bio!: string | null; + + @Property({ + type: 'varchar', + length: 255, + nullable: true, + }) + public avatar!: string | null; + + /** + * Private field - only owner or admin can see + */ + @Property({ + type: 'varchar', + length: 200, + nullable: true, + }) + public phone!: string | null; + + /** + * Private field - only owner or admin can see + */ + @Property({ + type: 'decimal', + precision: 10, + scale: 2, + nullable: true, + }) + public salary!: number | null; + + /** + * Privacy setting - if false, profile is private + */ + @Property({ + name: 'is_public', + type: 'boolean', + default: true, + }) + public isPublic!: boolean; + + @Enum(() => UserRole) + @Property({ + type: 'varchar', + length: 20, + default: UserRole.user, + }) + public role!: UserRole; + + @Property({ + length: 0, + name: 'created_at', + nullable: false, + defaultRaw: 'CURRENT_TIMESTAMP(0)', + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + createdAt: Date = new Date(); + + @Property({ + length: 0, + onUpdate: () => new Date(), + name: 'updated_at', + nullable: false, + columnType: 'timestamp(0) without time zone', + defaultRaw: 'CURRENT_TIMESTAMP(0)', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/microorm-database/src/lib/entities/acl-test/user.entity.ts b/libs/microorm-database/src/lib/entities/acl-test/user.entity.ts new file mode 100644 index 00000000..041e413b --- /dev/null +++ b/libs/microorm-database/src/lib/entities/acl-test/user.entity.ts @@ -0,0 +1,129 @@ +import { + Entity, + PrimaryKey, + Property, + OneToOne, + Collection, + OneToMany, +} from '@mikro-orm/core'; + +// +import { IUserProfileAcl, UserProfileAcl } from './user-profile.entity'; +import { IPostAcl, PostAcl } from './post.entity'; +import { CommentAcl, ICommentAcl } from './comment.entity'; +import { TagAcl, ITagAcl } from './tag.entity'; +import { ArticleAcl, IArticleAcl } from './article.entity'; +import { DocumentAcl, IDocumentAcl } from './document.entity'; + +export type IUsersAcl = UsersAcl; + +@Entity({ + tableName: 'acl_users', +}) +export class UsersAcl { + @PrimaryKey({ + autoincrement: true, + }) + public id!: number; + + @Property({ + type: 'varchar', + length: 100, + nullable: false, + unique: true, + }) + public login!: string; + + @Property({ + name: 'first_name', + type: 'varchar', + length: 100, + nullable: true, + default: 'NULL', + }) + public firstName!: string; + + @Property({ + name: 'last_name', + type: 'varchar', + length: 100, + nullable: true, + default: 'NULL', + }) + public lastName!: string; + + @Property({ + name: 'is_active', + type: 'boolean', + nullable: true, + default: false, + }) + public isActive!: boolean; + + @Property({ + length: 0, + name: 'created_at', + nullable: true, + defaultRaw: 'CURRENT_TIMESTAMP(0)', + columnType: 'timestamp(0) without time zone', + type: 'timestamp', + }) + createdAt: Date = new Date(); + + @Property({ + length: 0, + onUpdate: () => new Date(), + name: 'updated_at', + nullable: true, + columnType: 'timestamp(0) without time zone', + defaultRaw: 'CURRENT_TIMESTAMP(0)', + }) + updatedAt: Date = new Date(); + + // ======================================== + // ACL Test Entities Relationships + // ======================================== + + /** + * One-to-One reverse relationship with UserProfile + * User has one profile + */ + @OneToOne(() => UserProfileAcl, 'user') + public profile!: IUserProfileAcl; + + /** + * One-to-Many: User authored posts + */ + @OneToMany(() => PostAcl, 'author') + public posts = new Collection(this); + + /** + * One-to-Many: User authored comments + */ + @OneToMany(() => CommentAcl, 'author') + public aclComments = new Collection(this); + + /** + * One-to-Many: Tags created by user + */ + @OneToMany(() => TagAcl, 'createdBy') + public createdTags = new Collection(this); + + /** + * One-to-Many: Articles authored by user + */ + @OneToMany(() => ArticleAcl, 'author') + public authoredArticles = new Collection(this); + + /** + * One-to-Many: Articles edited by user + */ + @OneToMany(() => ArticleAcl, 'editor') + public editedArticles = new Collection(this); + + /** + * One-to-Many: Documents owned by user + */ + @OneToMany(() => DocumentAcl, 'owner') + public documents = new Collection(this); +} diff --git a/libs/microorm-database/src/lib/entities/index.ts b/libs/microorm-database/src/lib/entities/index.ts index cd841496..021bf791 100644 --- a/libs/microorm-database/src/lib/entities/index.ts +++ b/libs/microorm-database/src/lib/entities/index.ts @@ -3,3 +3,5 @@ export * from './roles'; export * from './addresses'; export * from './comments'; export * from './book-list'; + +export * from './acl-test' diff --git a/libs/microorm-database/src/lib/entities/users.ts b/libs/microorm-database/src/lib/entities/users.ts index df71bb83..e68d07a0 100644 --- a/libs/microorm-database/src/lib/entities/users.ts +++ b/libs/microorm-database/src/lib/entities/users.ts @@ -6,7 +6,6 @@ import { OneToOne, Collection, OneToMany, - ArrayType, } from '@mikro-orm/core'; import { Roles, Addresses, IAddresses, Comments, BookList } from './'; diff --git a/libs/microorm-database/src/lib/micro-orm-database.module.ts b/libs/microorm-database/src/lib/micro-orm-database.module.ts index cab84d50..3c0fe12a 100644 --- a/libs/microorm-database/src/lib/micro-orm-database.module.ts +++ b/libs/microorm-database/src/lib/micro-orm-database.module.ts @@ -1,13 +1,78 @@ -import { Module } from '@nestjs/common'; -import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { MikroOrmCoreModule } from '@mikro-orm/nestjs/mikro-orm-core.module'; +import { + type DynamicModule, + Inject, + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from '@nestjs/common'; +import { + getMikroORMToken, + MikroOrmModule, + MikroOrmModuleFeatureOptions, +} from '@mikro-orm/nestjs'; -import { config } from './config'; +import { config, CONTEXT_STORE_NAME } from './config'; +import { ModuleRef } from '@nestjs/core'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { AnyEntity, EntityName, MikroORM } from '@mikro-orm/core'; +import { ClsService } from 'nestjs-cls'; -@Module({ - imports: [MikroOrmModule.forRoot(config), MikroOrmModule.forMiddleware()], - exports: [MikroOrmCoreModule], -}) -export class MicroOrmDatabaseModule {} +@Module({}) +export class MicroOrmDatabaseModule implements NestModule { + static async forRoot(): Promise { + const MikroOrmDynamicModule = await MikroOrmModule.forRoot(config); -export { config }; + return { + module: MicroOrmDatabaseModule, + imports: [MikroOrmDynamicModule], + exports: [MikroOrmDynamicModule], + }; + } + + static forFeature( + options: EntityName[] | MikroOrmModuleFeatureOptions, + contextName?: string + ): DynamicModule { + const { providers, exports } = MikroOrmModule.forFeature( + options, + contextName + ); + + return { + module: MicroOrmDatabaseModule, + providers, + exports, + }; + } + + @Inject(ModuleRef) private readonly moduleRef!: ModuleRef; + configure(consumer: MiddlewareConsumer) { + const { contextName } = config; + + const orm = this.moduleRef.get(getMikroORMToken(contextName), { + strict: false, + }); + + const clsService = this.moduleRef.get(ClsService, { + strict: false, + }); + + consumer + .apply( + ( + req: IncomingMessage, + res: ServerResponse, + next: (error?: any) => void + ) => { + const forkEm = orm.em.fork({ + useContext: true, + loggerContext: { contextName }, + }); + clsService.set(CONTEXT_STORE_NAME, forkEm); + clsService.run({ ifNested: 'inherit' }, next); + } + ) + .forRoutes({ path: '/{*splat}', method: RequestMethod.ALL }); + } +} diff --git a/libs/microorm-database/src/lib/migrations/.snapshot-microorm-test.json b/libs/microorm-database/src/lib/migrations/.snapshot-microorm-test.json deleted file mode 100644 index 52e0868e..00000000 --- a/libs/microorm-database/src/lib/migrations/.snapshot-microorm-test.json +++ /dev/null @@ -1,726 +0,0 @@ -{ - "namespaces": [ - "public" - ], - "name": "public", - "tables": [ - { - "columns": { - "id": { - "name": "id", - "type": "serial", - "unsigned": false, - "autoincrement": true, - "primary": true, - "nullable": false, - "mappedType": "integer" - }, - "city": { - "name": "city", - "type": "varchar", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 70, - "mappedType": "string" - }, - "state": { - "name": "state", - "type": "varchar", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 70, - "mappedType": "string" - }, - "country": { - "name": "country", - "type": "varchar", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 68, - "mappedType": "string" - }, - "created_at": { - "name": "created_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - } - }, - "name": "addresses", - "schema": "public", - "indexes": [ - { - "keyName": "addresses_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": {}, - "nativeEnums": { - "comment_kind_enum": { - "name": "comment_kind_enum", - "schema": "public", - "items": [ - "COMMENT", - "MESSAGE", - "NOTE" - ] - } - } - }, - { - "columns": { - "id": { - "name": "id", - "type": "uuid", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "default": "uuid_generate_v4()", - "mappedType": "uuid" - }, - "text": { - "name": "text", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "created_at": { - "name": "created_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - } - }, - "name": "book_list", - "schema": "public", - "indexes": [ - { - "keyName": "book_list_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": {}, - "nativeEnums": { - "comment_kind_enum": { - "name": "comment_kind_enum", - "schema": "public", - "items": [ - "COMMENT", - "MESSAGE", - "NOTE" - ] - } - } - }, - { - "columns": { - "id": { - "name": "id", - "type": "serial", - "unsigned": false, - "autoincrement": true, - "primary": true, - "nullable": false, - "mappedType": "integer" - }, - "name": { - "name": "name", - "type": "varchar(128)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 128, - "default": "'NULL'", - "mappedType": "string" - }, - "key": { - "name": "key", - "type": "varchar(128)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 128, - "mappedType": "string" - }, - "is_default": { - "name": "is_default", - "type": "boolean", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "default": "false", - "mappedType": "boolean" - }, - "created_at": { - "name": "created_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - } - }, - "name": "roles", - "schema": "public", - "indexes": [ - { - "columnNames": [ - "key" - ], - "composite": false, - "keyName": "roles_key_unique", - "constraint": true, - "primary": false, - "unique": true - }, - { - "keyName": "roles_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": {}, - "nativeEnums": { - "comment_kind_enum": { - "name": "comment_kind_enum", - "schema": "public", - "items": [ - "COMMENT", - "MESSAGE", - "NOTE" - ] - } - } - }, - { - "columns": { - "id": { - "name": "id", - "type": "serial", - "unsigned": false, - "autoincrement": true, - "primary": true, - "nullable": false, - "mappedType": "integer" - }, - "login": { - "name": "login", - "type": "varchar(100)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 100, - "mappedType": "string" - }, - "first_name": { - "name": "first_name", - "type": "varchar(100)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 100, - "default": "'NULL'", - "mappedType": "string" - }, - "last_name": { - "name": "last_name", - "type": "varchar(100)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 100, - "default": "'NULL'", - "mappedType": "string" - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "default": "false", - "mappedType": "boolean" - }, - "created_at": { - "name": "created_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - }, - "addresses_id": { - "name": "addresses_id", - "type": "int", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "integer" - }, - "manager_id": { - "name": "manager_id", - "type": "int", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "integer" - } - }, - "name": "users", - "schema": "public", - "indexes": [ - { - "columnNames": [ - "login" - ], - "composite": false, - "keyName": "users_login_unique", - "constraint": true, - "primary": false, - "unique": true - }, - { - "columnNames": [ - "addresses_id" - ], - "composite": false, - "keyName": "users_addresses_id_unique", - "constraint": true, - "primary": false, - "unique": true - }, - { - "columnNames": [ - "manager_id" - ], - "composite": false, - "keyName": "users_manager_id_unique", - "constraint": true, - "primary": false, - "unique": true - }, - { - "keyName": "users_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "users_addresses_id_foreign": { - "constraintName": "users_addresses_id_foreign", - "columnNames": [ - "addresses_id" - ], - "localTableName": "public.users", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.addresses", - "updateRule": "cascade" - }, - "users_manager_id_foreign": { - "constraintName": "users_manager_id_foreign", - "columnNames": [ - "manager_id" - ], - "localTableName": "public.users", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.users", - "deleteRule": "set null", - "updateRule": "cascade" - } - }, - "nativeEnums": { - "comment_kind_enum": { - "name": "comment_kind_enum", - "schema": "public", - "items": [ - "COMMENT", - "MESSAGE", - "NOTE" - ] - } - } - }, - { - "columns": { - "id": { - "name": "id", - "type": "serial", - "unsigned": false, - "autoincrement": true, - "primary": true, - "nullable": false, - "mappedType": "integer" - }, - "text": { - "name": "text", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "text" - }, - "kind": { - "name": "kind", - "type": "comment_kind_enum", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "nativeEnumName": "comment_kind_enum", - "enumItems": [ - "COMMENT", - "MESSAGE", - "NOTE" - ], - "mappedType": "enum" - }, - "created_at": { - "name": "created_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp(0) without time zone", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "length": 0, - "default": "current_timestamp(0)", - "mappedType": "datetime" - }, - "created_by": { - "name": "created_by", - "type": "int", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "integer" - } - }, - "name": "comments", - "schema": "public", - "indexes": [ - { - "keyName": "comments_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "comments_created_by_foreign": { - "constraintName": "comments_created_by_foreign", - "columnNames": [ - "created_by" - ], - "localTableName": "public.comments", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.users", - "updateRule": "cascade" - } - }, - "nativeEnums": { - "comment_kind_enum": { - "name": "comment_kind_enum", - "schema": "public", - "items": [ - "COMMENT", - "MESSAGE", - "NOTE" - ] - } - } - }, - { - "columns": { - "users_id": { - "name": "users_id", - "type": "int", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "integer" - }, - "book_list_id": { - "name": "book_list_id", - "type": "uuid", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "uuid" - } - }, - "name": "users_have_book", - "schema": "public", - "indexes": [ - { - "keyName": "users_have_book_pkey", - "columnNames": [ - "users_id", - "book_list_id" - ], - "composite": true, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "users_have_book_users_id_foreign": { - "constraintName": "users_have_book_users_id_foreign", - "columnNames": [ - "users_id" - ], - "localTableName": "public.users_have_book", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.users", - "deleteRule": "cascade", - "updateRule": "cascade" - }, - "users_have_book_book_list_id_foreign": { - "constraintName": "users_have_book_book_list_id_foreign", - "columnNames": [ - "book_list_id" - ], - "localTableName": "public.users_have_book", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.book_list", - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, - "nativeEnums": { - "comment_kind_enum": { - "name": "comment_kind_enum", - "schema": "public", - "items": [ - "COMMENT", - "MESSAGE", - "NOTE" - ] - } - } - }, - { - "columns": { - "users_id": { - "name": "users_id", - "type": "int", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "integer" - }, - "roles_id": { - "name": "roles_id", - "type": "int", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "integer" - } - }, - "name": "users_have_roles", - "schema": "public", - "indexes": [ - { - "keyName": "users_have_roles_pkey", - "columnNames": [ - "users_id", - "roles_id" - ], - "composite": true, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "users_have_roles_users_id_foreign": { - "constraintName": "users_have_roles_users_id_foreign", - "columnNames": [ - "users_id" - ], - "localTableName": "public.users_have_roles", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.users", - "deleteRule": "cascade", - "updateRule": "cascade" - }, - "users_have_roles_roles_id_foreign": { - "constraintName": "users_have_roles_roles_id_foreign", - "columnNames": [ - "roles_id" - ], - "localTableName": "public.users_have_roles", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.roles", - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, - "nativeEnums": { - "comment_kind_enum": { - "name": "comment_kind_enum", - "schema": "public", - "items": [ - "COMMENT", - "MESSAGE", - "NOTE" - ] - } - } - } - ], - "nativeEnums": { - "comment_kind_enum": { - "name": "comment_kind_enum", - "schema": "public", - "items": [ - "COMMENT", - "MESSAGE", - "NOTE" - ] - } - } -} diff --git a/libs/microorm-database/src/lib/migrations/.snapshot-mikroorm-database.json b/libs/microorm-database/src/lib/migrations/.snapshot-mikroorm-database.json new file mode 100644 index 00000000..8e10f3c6 --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/.snapshot-mikroorm-database.json @@ -0,0 +1,2081 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "city": { + "name": "city", + "type": "varchar", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 70, + "mappedType": "string" + }, + "state": { + "name": "state", + "type": "varchar", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 70, + "mappedType": "string" + }, + "country": { + "name": "country", + "type": "varchar", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 68, + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "addresses", + "schema": "public", + "indexes": [ + { + "keyName": "addresses_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "uuid_generate_v4()", + "mappedType": "uuid" + }, + "text": { + "name": "text", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "book_list", + "schema": "public", + "indexes": [ + { + "keyName": "book_list_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 150, + "mappedType": "string" + }, + "parent_id": { + "name": "parent_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "integer" + }, + "level": { + "name": "level", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "0", + "mappedType": "integer" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "true", + "mappedType": "boolean" + }, + "description": { + "name": "description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "acl_categories", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "slug" + ], + "composite": false, + "keyName": "acl_categories_slug_unique", + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "acl_categories_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "acl_categories_parent_id_foreign": { + "constraintName": "acl_categories_parent_id_foreign", + "columnNames": [ + "parent_id" + ], + "localTableName": "public.acl_categories", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_categories", + "deleteRule": "set null", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "acl_rules": { + "name": "acl_rules", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'{}'", + "mappedType": "json" + }, + "context": { + "name": "context", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'{}'", + "mappedType": "json" + } + }, + "name": "acl_context_test", + "schema": "public", + "indexes": [ + { + "keyName": "acl_context_test_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "name": { + "name": "name", + "type": "varchar(128)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 128, + "default": "'NULL'", + "mappedType": "string" + }, + "key": { + "name": "key", + "type": "varchar(128)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 128, + "mappedType": "string" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "roles", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "key" + ], + "composite": false, + "keyName": "roles_key_unique", + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "roles_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "login": { + "name": "login", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "default": "'NULL'", + "mappedType": "string" + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "default": "'NULL'", + "mappedType": "string" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "default": "false", + "mappedType": "boolean" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "addresses_id": { + "name": "addresses_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "manager_id": { + "name": "manager_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "integer" + } + }, + "name": "users", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "login" + ], + "composite": false, + "keyName": "users_login_unique", + "constraint": true, + "primary": false, + "unique": true + }, + { + "columnNames": [ + "addresses_id" + ], + "composite": false, + "keyName": "users_addresses_id_unique", + "constraint": true, + "primary": false, + "unique": true + }, + { + "columnNames": [ + "manager_id" + ], + "composite": false, + "keyName": "users_manager_id_unique", + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "users_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "users_addresses_id_foreign": { + "constraintName": "users_addresses_id_foreign", + "columnNames": [ + "addresses_id" + ], + "localTableName": "public.users", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.addresses", + "updateRule": "cascade" + }, + "users_manager_id_foreign": { + "constraintName": "users_manager_id_foreign", + "columnNames": [ + "manager_id" + ], + "localTableName": "public.users", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.users", + "deleteRule": "set null", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "text": { + "name": "text", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "kind": { + "name": "kind", + "type": "comment_kind_enum", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "nativeEnumName": "comment_kind_enum", + "enumItems": [ + "COMMENT", + "MESSAGE", + "NOTE" + ], + "mappedType": "enum" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "created_by": { + "name": "created_by", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "integer" + } + }, + "name": "comments", + "schema": "public", + "indexes": [ + { + "keyName": "comments_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "comments_created_by_foreign": { + "constraintName": "comments_created_by_foreign", + "columnNames": [ + "created_by" + ], + "localTableName": "public.comments", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.users", + "deleteRule": "set null", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "users_id": { + "name": "users_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "book_list_id": { + "name": "book_list_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + } + }, + "name": "users_have_book", + "schema": "public", + "indexes": [ + { + "keyName": "users_have_book_pkey", + "columnNames": [ + "users_id", + "book_list_id" + ], + "composite": true, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "users_have_book_users_id_foreign": { + "constraintName": "users_have_book_users_id_foreign", + "columnNames": [ + "users_id" + ], + "localTableName": "public.users_have_book", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.users", + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "users_have_book_book_list_id_foreign": { + "constraintName": "users_have_book_book_list_id_foreign", + "columnNames": [ + "book_list_id" + ], + "localTableName": "public.users_have_book", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.book_list", + "deleteRule": "cascade", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "users_id": { + "name": "users_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "roles_id": { + "name": "roles_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + } + }, + "name": "users_have_roles", + "schema": "public", + "indexes": [ + { + "keyName": "users_have_roles_pkey", + "columnNames": [ + "users_id", + "roles_id" + ], + "composite": true, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "users_have_roles_users_id_foreign": { + "constraintName": "users_have_roles_users_id_foreign", + "columnNames": [ + "users_id" + ], + "localTableName": "public.users_have_roles", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.users", + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "users_have_roles_roles_id_foreign": { + "constraintName": "users_have_roles_roles_id_foreign", + "columnNames": [ + "roles_id" + ], + "localTableName": "public.users_have_roles", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.roles", + "deleteRule": "cascade", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "login": { + "name": "login", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "default": "'NULL'", + "mappedType": "string" + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "default": "'NULL'", + "mappedType": "string" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "default": "false", + "mappedType": "boolean" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "acl_users", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "login" + ], + "composite": false, + "keyName": "acl_users_login_unique", + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "acl_users_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "user_id": { + "name": "user_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "first_name": { + "name": "first_name", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "mappedType": "string" + }, + "last_name": { + "name": "last_name", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "mappedType": "string" + }, + "bio": { + "name": "bio", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "avatar": { + "name": "avatar", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 255, + "mappedType": "string" + }, + "phone": { + "name": "phone", + "type": "varchar(200)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 200, + "mappedType": "string" + }, + "salary": { + "name": "salary", + "type": "numeric(10,2)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "precision": 10, + "scale": 2, + "mappedType": "decimal" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "true", + "mappedType": "boolean" + }, + "role": { + "name": "role", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "admin", + "user", + "moderator" + ], + "mappedType": "enum" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "acl_user_profiles", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "user_id" + ], + "composite": false, + "keyName": "acl_user_profiles_user_id_unique", + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "acl_user_profiles_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "acl_user_profiles_user_id_foreign": { + "constraintName": "acl_user_profiles_user_id_foreign", + "columnNames": [ + "user_id" + ], + "localTableName": "public.acl_user_profiles", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_users", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 150, + "mappedType": "string" + }, + "created_by_id": { + "name": "created_by_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "is_official": { + "name": "is_official", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "description": { + "name": "description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "acl_tags", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "slug" + ], + "composite": false, + "keyName": "acl_tags_slug_unique", + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "acl_tags_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "acl_tags_created_by_id_foreign": { + "constraintName": "acl_tags_created_by_id_foreign", + "columnNames": [ + "created_by_id" + ], + "localTableName": "public.acl_tags", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_users", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 255, + "mappedType": "string" + }, + "content": { + "name": "content", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "author_id": { + "name": "author_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "category_id": { + "name": "category_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "integer" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "draft", + "published", + "archived" + ], + "mappedType": "enum" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "mappedType": "datetime" + }, + "view_count": { + "name": "view_count", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "0", + "mappedType": "integer" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "acl_posts", + "schema": "public", + "indexes": [ + { + "keyName": "acl_posts_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "acl_posts_author_id_foreign": { + "constraintName": "acl_posts_author_id_foreign", + "columnNames": [ + "author_id" + ], + "localTableName": "public.acl_posts", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_users", + "updateRule": "cascade" + }, + "acl_posts_category_id_foreign": { + "constraintName": "acl_posts_category_id_foreign", + "columnNames": [ + "category_id" + ], + "localTableName": "public.acl_posts", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_categories", + "deleteRule": "set null", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "post_acl_id": { + "name": "post_acl_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "tag_acl_id": { + "name": "tag_acl_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + } + }, + "name": "acl_posts_tags", + "schema": "public", + "indexes": [ + { + "keyName": "acl_posts_tags_pkey", + "columnNames": [ + "post_acl_id", + "tag_acl_id" + ], + "composite": true, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "acl_posts_tags_post_acl_id_foreign": { + "constraintName": "acl_posts_tags_post_acl_id_foreign", + "columnNames": [ + "post_acl_id" + ], + "localTableName": "public.acl_posts_tags", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_posts", + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "acl_posts_tags_tag_acl_id_foreign": { + "constraintName": "acl_posts_tags_tag_acl_id_foreign", + "columnNames": [ + "tag_acl_id" + ], + "localTableName": "public.acl_posts_tags", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_tags", + "deleteRule": "cascade", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "filename": { + "name": "filename", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 255, + "mappedType": "string" + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "size": { + "name": "size", + "type": "bigint", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "bigint" + }, + "path": { + "name": "path", + "type": "varchar(500)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 500, + "mappedType": "string" + }, + "owner_id": { + "name": "owner_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "shared_with": { + "name": "shared_with", + "type": "integer[]", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'{}'", + "mappedType": "array" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "acl_documents", + "schema": "public", + "indexes": [ + { + "keyName": "acl_documents_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "acl_documents_owner_id_foreign": { + "constraintName": "acl_documents_owner_id_foreign", + "columnNames": [ + "owner_id" + ], + "localTableName": "public.acl_documents", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_users", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "post_id": { + "name": "post_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "author_id": { + "name": "author_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "content": { + "name": "content", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "is_approved": { + "name": "is_approved", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "is_edited": { + "name": "is_edited", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "acl_comments", + "schema": "public", + "indexes": [ + { + "keyName": "acl_comments_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "acl_comments_post_id_foreign": { + "constraintName": "acl_comments_post_id_foreign", + "columnNames": [ + "post_id" + ], + "localTableName": "public.acl_comments", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_posts", + "updateRule": "cascade" + }, + "acl_comments_author_id_foreign": { + "constraintName": "acl_comments_author_id_foreign", + "columnNames": [ + "author_id" + ], + "localTableName": "public.acl_comments", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_users", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + }, + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": false, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 255, + "mappedType": "string" + }, + "content": { + "name": "content", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "author_id": { + "name": "author_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "co_author_ids": { + "name": "co_author_ids", + "type": "integer[]", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'{}'", + "mappedType": "array" + }, + "editor_id": { + "name": "editor_id", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "integer" + }, + "status": { + "name": "status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "draft", + "review", + "published" + ], + "mappedType": "enum" + }, + "visibility": { + "name": "visibility", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "public", + "private", + "unlisted" + ], + "mappedType": "enum" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'{\"readTime\": 0, \"featured\": false, \"premium\": false}'", + "mappedType": "json" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "mappedType": "datetime" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + } + }, + "name": "acl_articles", + "schema": "public", + "indexes": [ + { + "keyName": "acl_articles_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "acl_articles_author_id_foreign": { + "constraintName": "acl_articles_author_id_foreign", + "columnNames": [ + "author_id" + ], + "localTableName": "public.acl_articles", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_users", + "updateRule": "cascade" + }, + "acl_articles_editor_id_foreign": { + "constraintName": "acl_articles_editor_id_foreign", + "columnNames": [ + "editor_id" + ], + "localTableName": "public.acl_articles", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.acl_users", + "deleteRule": "set null", + "updateRule": "cascade" + } + }, + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } + } + ], + "nativeEnums": { + "comment_kind_enum": { + "name": "comment_kind_enum", + "schema": "public", + "items": [ + "COMMENT", + "MESSAGE", + "NOTE" + ] + } + } +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20250123130345_CreateUsersCommentsRelations.ts b/libs/microorm-database/src/lib/migrations/Migration20250123130345_CreateUsersCommentsRelations.ts index 223764d3..8a4a23c0 100644 --- a/libs/microorm-database/src/lib/migrations/Migration20250123130345_CreateUsersCommentsRelations.ts +++ b/libs/microorm-database/src/lib/migrations/Migration20250123130345_CreateUsersCommentsRelations.ts @@ -3,8 +3,8 @@ import { Migration } from '@mikro-orm/migrations'; export class Migration20250123130345_CreateUsersCommentsRelations extends Migration { override async up(): Promise { - this.addSql(`alter table "comments" add column "created_by" int not null;`); - this.addSql(`alter table "comments" add constraint "comments_created_by_foreign" foreign key ("created_by") references "users" ("id") on update cascade;`); + this.addSql(`alter table "comments" add column "created_by" int;`); + this.addSql(`alter table "comments" add constraint "comments_created_by_foreign" foreign key ("created_by") references "users" ("id") on update cascade on delete set null;`); } override async down(): Promise { diff --git a/libs/microorm-database/src/lib/migrations/Migration20251022142630_CreateUsersAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251022142630_CreateUsersAcl.ts new file mode 100644 index 00000000..c891ddea --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251022142630_CreateUsersAcl.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251022142630_CreateUsersAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_users" ("id" serial primary key, "login" varchar(100) not null, "first_name" varchar(100) null default 'NULL', "last_name" varchar(100) null default 'NULL', "is_active" boolean null default false, "created_at" timestamp(0) without time zone null default current_timestamp(0), "updated_at" timestamp(0) without time zone null default current_timestamp(0));`); + this.addSql(`alter table "acl_users" add constraint "acl_users_login_unique" unique ("login");`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "acl_users" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20251022142746_CreateUserProfileAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251022142746_CreateUserProfileAcl.ts new file mode 100644 index 00000000..4196a65c --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251022142746_CreateUserProfileAcl.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251022142746_CreateUserProfileAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_user_profiles" ("id" serial primary key, "user_id" int not null, "first_name" varchar(100) null, "last_name" varchar(100) null, "bio" text null, "avatar" varchar(255) null, "phone" varchar(200) null, "salary" numeric(10,2) null, "is_public" boolean not null default true, "role" text check ("role" in ('admin', 'user', 'moderator')) not null, "created_at" timestamp(0) without time zone not null default current_timestamp(0), "updated_at" timestamp(0) without time zone not null default current_timestamp(0));`); + this.addSql(`alter table "acl_user_profiles" add constraint "acl_user_profiles_user_id_unique" unique ("user_id");`); + + this.addSql(`alter table "acl_user_profiles" add constraint "acl_user_profiles_user_id_foreign" foreign key ("user_id") references "acl_users" ("id") on update cascade;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "acl_user_profiles" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20251022142853_CreateCategoryAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251022142853_CreateCategoryAcl.ts new file mode 100644 index 00000000..fb6b22ca --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251022142853_CreateCategoryAcl.ts @@ -0,0 +1,18 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251022142853_CreateCategoryAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_categories" ("id" serial primary key, "name" varchar(100) not null, "slug" varchar(150) not null, "parent_id" int null, "level" int not null default 0, "is_active" boolean not null default true, "description" text null, "created_at" timestamp(0) without time zone not null default current_timestamp(0), "updated_at" timestamp(0) without time zone not null default current_timestamp(0));`); + this.addSql(`alter table "acl_categories" add constraint "acl_categories_slug_unique" unique ("slug");`); + + this.addSql(`alter table "acl_categories" add constraint "acl_categories_parent_id_foreign" foreign key ("parent_id") references "acl_categories" ("id") on update cascade on delete set null;`); + } + + override async down(): Promise { + this.addSql(`alter table "acl_categories" drop constraint "acl_categories_parent_id_foreign";`); + + this.addSql(`drop table if exists "acl_categories" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20251022143027_CreatePostAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251022143027_CreatePostAcl.ts new file mode 100644 index 00000000..fc3dcec6 --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251022143027_CreatePostAcl.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251022143027_CreatePostAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_posts" ("id" serial primary key, "title" varchar(255) not null, "content" text not null, "excerpt" text null, "author_id" int not null, "category_id" int null, "status" text check ("status" in ('draft', 'published', 'archived')) not null, "is_published" boolean not null default false, "published_at" timestamp(0) without time zone null, "view_count" int not null default 0, "created_at" timestamp(0) without time zone not null default current_timestamp(0), "updated_at" timestamp(0) without time zone not null default current_timestamp(0));`); + + this.addSql(`alter table "acl_posts" add constraint "acl_posts_author_id_foreign" foreign key ("author_id") references "acl_users" ("id") on update cascade;`); + this.addSql(`alter table "acl_posts" add constraint "acl_posts_category_id_foreign" foreign key ("category_id") references "acl_categories" ("id") on update cascade on delete set null;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "acl_posts" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20251022143142_CreateCommentAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251022143142_CreateCommentAcl.ts new file mode 100644 index 00000000..52123e29 --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251022143142_CreateCommentAcl.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251022143142_CreateCommentAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_comments" ("id" serial primary key, "post_id" int not null, "author_id" int not null, "content" text not null, "is_approved" boolean not null default false, "is_edited" boolean not null default false, "created_at" timestamp(0) without time zone not null default current_timestamp(0), "updated_at" timestamp(0) without time zone not null default current_timestamp(0));`); + + this.addSql(`alter table "acl_comments" add constraint "acl_comments_post_id_foreign" foreign key ("post_id") references "acl_posts" ("id") on update cascade;`); + this.addSql(`alter table "acl_comments" add constraint "acl_comments_author_id_foreign" foreign key ("author_id") references "acl_users" ("id") on update cascade;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "acl_comments" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20251022143242_CreateTagAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251022143242_CreateTagAcl.ts new file mode 100644 index 00000000..6f103dac --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251022143242_CreateTagAcl.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251022143242_CreateTagAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_tags" ("id" serial primary key, "name" varchar(100) not null, "slug" varchar(150) not null, "created_by_id" int not null, "is_official" boolean not null default false, "description" text null, "created_at" timestamp(0) without time zone not null default current_timestamp(0), "updated_at" timestamp(0) without time zone not null default current_timestamp(0));`); + this.addSql(`alter table "acl_tags" add constraint "acl_tags_slug_unique" unique ("slug");`); + + this.addSql(`create table "acl_posts_tags" ("post_acl_id" int not null, "tag_acl_id" int not null, constraint "acl_posts_tags_pkey" primary key ("post_acl_id", "tag_acl_id"));`); + + this.addSql(`alter table "acl_tags" add constraint "acl_tags_created_by_id_foreign" foreign key ("created_by_id") references "acl_users" ("id") on update cascade;`); + + this.addSql(`alter table "acl_posts_tags" add constraint "acl_posts_tags_post_acl_id_foreign" foreign key ("post_acl_id") references "acl_posts" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table "acl_posts_tags" add constraint "acl_posts_tags_tag_acl_id_foreign" foreign key ("tag_acl_id") references "acl_tags" ("id") on update cascade on delete cascade;`); + } + + override async down(): Promise { + this.addSql(`alter table "acl_posts_tags" drop constraint "acl_posts_tags_tag_acl_id_foreign";`); + + this.addSql(`drop table if exists "acl_tags" cascade;`); + + this.addSql(`drop table if exists "acl_posts_tags" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20251022143716_CreateArticleAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251022143716_CreateArticleAcl.ts new file mode 100644 index 00000000..8a29a5f4 --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251022143716_CreateArticleAcl.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251022143716_CreateArticleAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_articles" ("id" serial primary key, "title" varchar(255) not null, "content" text not null, "author_id" int not null, "co_author_ids" integer[] not null default '{}', "editor_id" int null, "status" text check ("status" in ('draft', 'review', 'published')) not null, "visibility" text check ("visibility" in ('public', 'private', 'unlisted')) not null, "metadata" jsonb not null default '{"readTime": 0, "featured": false, "premium": false}', "published_at" timestamp(0) without time zone null, "expires_at" timestamp(0) without time zone null, "created_at" timestamp(0) without time zone not null default current_timestamp(0), "updated_at" timestamp(0) without time zone not null default current_timestamp(0));`); + + this.addSql(`alter table "acl_articles" add constraint "acl_articles_author_id_foreign" foreign key ("author_id") references "acl_users" ("id") on update cascade;`); + this.addSql(`alter table "acl_articles" add constraint "acl_articles_editor_id_foreign" foreign key ("editor_id") references "acl_users" ("id") on update cascade on delete set null;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "acl_articles" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20251022143819_CreateDocumentAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251022143819_CreateDocumentAcl.ts new file mode 100644 index 00000000..b6ef3d9a --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251022143819_CreateDocumentAcl.ts @@ -0,0 +1,15 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251022143819_CreateDocumentAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_documents" ("id" serial primary key, "filename" varchar(255) not null, "mime_type" varchar(100) not null, "size" bigint not null, "path" varchar(500) not null, "owner_id" int not null, "shared_with" integer[] not null default '{}', "is_public" boolean not null default false, "uploaded_at" timestamp(0) without time zone not null default current_timestamp(0), "updated_at" timestamp(0) without time zone not null default current_timestamp(0));`); + + this.addSql(`alter table "acl_documents" add constraint "acl_documents_owner_id_foreign" foreign key ("owner_id") references "acl_users" ("id") on update cascade;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "acl_documents" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/migrations/Migration20251025094321_CreateContextTestAcl.ts b/libs/microorm-database/src/lib/migrations/Migration20251025094321_CreateContextTestAcl.ts new file mode 100644 index 00000000..0df0d407 --- /dev/null +++ b/libs/microorm-database/src/lib/migrations/Migration20251025094321_CreateContextTestAcl.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251025094321_CreateContextTestAcl extends Migration { + + override async up(): Promise { + this.addSql(`create table "acl_context_test" ("id" serial primary key, "acl_rules" jsonb not null default '{}', "context" jsonb not null default '{}');`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "acl_context_test" cascade;`); + } + +} diff --git a/libs/microorm-database/src/lib/seeders/acl/acl.seed.ts b/libs/microorm-database/src/lib/seeders/acl/acl.seed.ts new file mode 100644 index 00000000..2bbfd7f8 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/acl.seed.ts @@ -0,0 +1,82 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { + CategoryAcl, + UsersAcl, + PostAcl, + TagAcl, + CommentAcl, + ArticleAcl, + DocumentAcl, +} from '../../entities/acl-test'; + +import { Context } from '../database.seeder'; + +export type InnerAclContext = { + users: UsersAcl[]; + categories: CategoryAcl[]; + posts: PostAcl[]; + tags: TagAcl[]; + comments: CommentAcl[]; + articles: ArticleAcl[]; + documents: DocumentAcl[]; +}; + +export type AclContext = Context & { aclContext: InnerAclContext }; + +import { + UsersSeed, + CategorySeed, + PostSeed, + TagSeed, + CommentSeed, + DocumentSeed, + ArticleSeed, +} from './seeds'; + +/** + * ACL Test Data Seeder + * + * Creates comprehensive test data for all ACL permission scenarios: + * - Ownership patterns (author, owner, creator) + * - Role-based access (admin, moderator, user) + * - Status-based filtering (draft, published, archived) + * - Field-level permissions (private fields like phone, salary) + * - Relationship permissions (includes, joins) + * - Array conditions (co-authors, shared users) + * - Time-based access (expires_at) + */ +export class AclSeed extends Seeder { + async run(em: EntityManager, context: Context = {}): Promise { + const innerContext: InnerAclContext = { + users: [], + categories: [], + posts: [], + tags: [], + comments: [], + articles: [], + documents: [], + }; + const aclContext = { + ...context, + aclContext: innerContext, + }; + await this.call(em, [UsersSeed, CategorySeed], aclContext); + await this.call(em, [TagSeed], aclContext); + await this.call(em, [PostSeed], aclContext); + await this.call(em, [CommentSeed, DocumentSeed, ArticleSeed], aclContext); + + console.log('✅ ACL seed data created successfully!'); + console.log(` +📊 Created: + - 6 Users (admin, moderator, alice, bob, charlie, inactive) + - 6 UserProfiles (public/private) + - 6 Categories (hierarchical structure) + - 7 Posts (published/draft/archived) + - 5 Tags (official/user-created) + - 7 Comments (approved/pending) + - 5 Articles (complex scenarios) + - 5 Documents (public/shared/private) + `); + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/article.factory.ts b/libs/microorm-database/src/lib/seeders/acl/factory/article.factory.ts new file mode 100644 index 00000000..1211b35a --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/article.factory.ts @@ -0,0 +1,25 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { ArticleAcl, ArticleStatus, ArticleVisibility } from '../../../entities/acl-test'; + +export class ArticleFactory extends Factory { + model = ArticleAcl; + + definition(): Partial { + return { + title: faker.lorem.sentence(), + content: faker.lorem.paragraphs(8), + coAuthorIds: [], + status: ArticleStatus.PUBLISHED, + visibility: ArticleVisibility.PUBLIC, + editor: null, + metadata: { + readTime: faker.number.int({ min: 5, max: 30 }), + featured: true, + premium: false, + }, + publishedAt: faker.date.past(), + expiresAt: null, + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/category.factory.ts b/libs/microorm-database/src/lib/seeders/acl/factory/category.factory.ts new file mode 100644 index 00000000..a1ced3b5 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/category.factory.ts @@ -0,0 +1,19 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { CategoryAcl } from '../../../entities/acl-test'; + +export class CategoryFactory extends Factory { + model = CategoryAcl; + + definition(): Partial { + const name = faker.word.words(2); + return { + name, + slug: faker.helpers.slugify(name).toLowerCase(), + description: faker.lorem.sentence(), + parent: null, + level: 0, + isActive: true, + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/comment.factory.ts b/libs/microorm-database/src/lib/seeders/acl/factory/comment.factory.ts new file mode 100644 index 00000000..e3d410a2 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/comment.factory.ts @@ -0,0 +1,15 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { CommentAcl } from '../../../entities/acl-test'; + +export class CommentFactory extends Factory { + model = CommentAcl; + + definition(): Partial { + return { + content: faker.lorem.paragraph(), + isApproved: true, + isEdited: false, + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/document.factory.ts b/libs/microorm-database/src/lib/seeders/acl/factory/document.factory.ts new file mode 100644 index 00000000..91b518c8 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/document.factory.ts @@ -0,0 +1,33 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { DocumentAcl } from '../../../entities/acl-test'; + +export class DocumentFactory extends Factory { + model = DocumentAcl; + + definition(): Partial { + const extensions = [ + { ext: 'pdf', mime: 'application/pdf' }, + { ext: 'jpg', mime: 'image/jpeg' }, + { ext: 'png', mime: 'image/png' }, + { ext: 'docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + { ext: 'xlsx', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + { ext: 'txt', mime: 'text/plain' }, + { ext: 'zip', mime: 'application/zip' }, + { ext: 'mp4', mime: 'video/mp4' }, + { ext: 'mp3', mime: 'audio/mpeg' }, + ]; + + const fileType = faker.helpers.arrayElement(extensions); + const filename = `${faker.word.noun()}-${faker.string.alphanumeric(8)}.${fileType.ext}`; + const dirPath = faker.system.directoryPath(); + return { + filename: filename, + mimeType: fileType.mime, + size: faker.number.int({ min: 1000, max: 10000000 }), + path: `/uploads${dirPath}/${filename}`, + sharedWith: [], + isPublic: false, + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/index.ts b/libs/microorm-database/src/lib/seeders/acl/factory/index.ts new file mode 100644 index 00000000..141221c8 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/index.ts @@ -0,0 +1,8 @@ +export * from './user.factory'; +export * from './user-profile.factory'; +export * from './category.factory'; +export * from './post.factory'; +export * from './tag.factory'; +export * from './comment.factory'; +export * from './article.factory'; +export * from './document.factory'; diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/post.factory.ts b/libs/microorm-database/src/lib/seeders/acl/factory/post.factory.ts new file mode 100644 index 00000000..b10189ea --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/post.factory.ts @@ -0,0 +1,19 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { PostAcl, PostStatus } from '../../../entities/acl-test'; + +export class PostFactory extends Factory { + model = PostAcl; + + definition(): Partial { + return { + title: faker.lorem.sentence(), + content: faker.lorem.paragraphs(5), + excerpt: faker.lorem.paragraph(), + status: PostStatus.PUBLISHED, + isPublished: true, + publishedAt: faker.date.past(), + viewCount: faker.number.int({ min: 0, max: 5000 }), + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/tag.factory.ts b/libs/microorm-database/src/lib/seeders/acl/factory/tag.factory.ts new file mode 100644 index 00000000..c17cc9c2 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/tag.factory.ts @@ -0,0 +1,17 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { TagAcl } from '../../../entities/acl-test'; + +export class TagFactory extends Factory { + model = TagAcl; + + definition(): Partial { + const name = faker.word.noun(); + return { + name, + slug: faker.helpers.slugify(name).toLowerCase(), + description: faker.lorem.sentence(), + isOfficial: false, + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/user-profile.factory.ts b/libs/microorm-database/src/lib/seeders/acl/factory/user-profile.factory.ts new file mode 100644 index 00000000..75fe3af1 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/user-profile.factory.ts @@ -0,0 +1,19 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { UserProfileAcl } from '../../../entities/acl-test'; + +export class UserProfileFactory extends Factory { + model = UserProfileAcl; + + definition(): Partial { + return { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + bio: faker.lorem.sentence(), + avatar: faker.image.avatar(), + phone: faker.phone.number(), + salary: faker.number.int({ min: 50000, max: 150000 }), + isPublic: faker.datatype.boolean(), + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/factory/user.factory.ts b/libs/microorm-database/src/lib/seeders/acl/factory/user.factory.ts new file mode 100644 index 00000000..7f91d56a --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/factory/user.factory.ts @@ -0,0 +1,19 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { UsersAcl } from '../../../entities/acl-test'; + +export class UsersAclFactory extends Factory { + model = UsersAcl; + + definition(): Partial { + const info = { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + } + return { + login: faker.internet.username(info).toLowerCase(), + isActive: true, + ...info + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/seeds/article.seed.ts b/libs/microorm-database/src/lib/seeders/acl/seeds/article.seed.ts new file mode 100644 index 00000000..54f94e99 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/seeds/article.seed.ts @@ -0,0 +1,60 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import type { EntityData } from '@mikro-orm/core'; +import { AclContext } from '../acl.seed'; +import { ArticleFactory } from '../factory'; +import { + ArticleAcl, + ArticleStatus, + ArticleVisibility, +} from '../../../entities'; +import { faker } from '@faker-js/faker'; + +export class ArticleSeed extends Seeder { + async run(em: EntityManager, context: AclContext): Promise { + const articleFactory = new ArticleFactory(em); + const alice = context.aclContext.users.find(user => user.login === 'alice')!; + const bob = context.aclContext.users.find(user => user.login === 'bob')!; + const moderator = context.aclContext.users.find(user => user.login === 'moderator')!; + const charlie = context.aclContext.users.find(user => user.login === 'charlie')!; + + const articleData: EntityData[] = [{ + title: 'Collaborative Article on AI', + author: alice, + coAuthorIds: [bob.id], + editor: moderator, + }, { + title: 'Premium Content for Members', + author: alice, + metadata: { + readTime: faker.number.int({ min: 5, max: 30 }), + featured: true, + premium: true, // ACL test: premium content + } + }, { + title: 'Article Under Review', + author: bob, + coAuthorIds: [charlie.id], + editor: moderator, + status: ArticleStatus.REVIEW, // ACL test: review status + visibility: ArticleVisibility.UNLISTED, + publishedAt: null, + }, { + title: 'Temporary Access Article', + author: charlie, + expiresAt: new Date('2024-12-31'), // ACL test: time-based access + }, { + title: 'Private Draft Article', + author: bob, + status: ArticleStatus.DRAFT, + visibility: ArticleVisibility.PRIVATE, // ACL test: private visibility + publishedAt: null, + expiresAt: null, + }] + + const count = articleData.length; + context.aclContext.articles = await articleFactory + .each((article) => Object.assign(article, articleData.shift())) + .create(count); + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/seeds/category.seed.ts b/libs/microorm-database/src/lib/seeders/acl/seeds/category.seed.ts new file mode 100644 index 00000000..e8f25280 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/seeds/category.seed.ts @@ -0,0 +1,64 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import type { EntityData } from '@mikro-orm/core'; +import { AclContext } from '../acl.seed'; +import { CategoryFactory } from '../factory'; +import { CategoryAcl } from '../../../entities'; + +export class CategorySeed extends Seeder { + async run(em: EntityManager, context: AclContext): Promise { + const categoryFactory = new CategoryFactory(em); + + const firstLevelCatData: EntityData[] = [ + { + name: 'Technology', + slug: 'technology', + description: 'Technology and programming topics', + }, + { + name: 'Lifestyle', + slug: 'lifestyle', + description: 'Lifestyle and personal development', + }, + { + name: 'Inactive Category', + slug: 'inactive-category', + description: 'This category is inactive', + isActive: false, + }, + ]; + const countFirstLevel = firstLevelCatData.length; + const [techCategory, lifestyleCategory, inactiveCategory] = await categoryFactory + .each((category) => Object.assign(category, firstLevelCatData.shift())) + .create(countFirstLevel); + + const secondLevelCatData: EntityData[] = [ + { + name: 'Web Development', + slug: 'web-development', + description: 'Frontend and backend web development', + parent: techCategory, + }, + { + name: 'Artificial Intelligence', + slug: 'artificial-intelligence', + description: 'AI and machine learning', + parent: techCategory, + }, + { + name: 'Health & Fitness', + slug: 'health-fitness', + description: 'Health tips and fitness guides', + parent: lifestyleCategory, + }, + ]; + const countSecondLevel = secondLevelCatData.length; + const [webDevCategory, aiCategory, healthCategory] = await categoryFactory + .each((category) => Object.assign(category, {level: 1}, secondLevelCatData.shift())) + .create(countSecondLevel); + + + + context.aclContext.categories = [techCategory, lifestyleCategory, inactiveCategory, webDevCategory, aiCategory, healthCategory] + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/seeds/comment.seed.ts b/libs/microorm-database/src/lib/seeders/acl/seeds/comment.seed.ts new file mode 100644 index 00000000..9c74934b --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/seeds/comment.seed.ts @@ -0,0 +1,54 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { AclContext } from '../acl.seed'; +import { CommentFactory } from '../factory'; +import { CommentAcl } from '../../../entities'; +import type { EntityData } from '@mikro-orm/core'; + +export class CommentSeed extends Seeder { + async run(em: EntityManager, context: AclContext): Promise { + const commentFactory = new CommentFactory(em); + const [post1, post2, post3, post4, post5, post6] = context.aclContext.posts; + const bob = context.aclContext.users.find(user => user.login === 'bob')!; + const charlie = context.aclContext.users.find(user => user.login === 'charlie')!; + const alice = context.aclContext.users.find(user => user.login === 'alice')!; + + const commentData: EntityData[] = [{ + post: post1, + author: bob, + content: 'Great tutorial! Very helpful for beginners.', + }, { + post: post1, + author: charlie, + content: 'Thanks for sharing this!', + }, { + post: post1, + author: alice, + content: 'Glad you found it useful!', + }, { + post: post2, + author: bob, + content: 'This needs moderation approval', + isApproved: false, // ACL test: only moderator/admin can see pending + }, { + post: post4, + author: alice, + content: 'Very informative article on health!', + }, { + post: post4, + author: charlie, + content: 'Pending comment for moderation', + isApproved: false, + }, { + post: post6, + author: bob, + content: 'TypeScript is awesome!', + isEdited: true, // Edited comment + }] + + const count = commentData.length; + context.aclContext.comments = await commentFactory + .each((comment) => Object.assign(comment, commentData.shift())) + .create(count); + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/seeds/document.seed.ts b/libs/microorm-database/src/lib/seeders/acl/seeds/document.seed.ts new file mode 100644 index 00000000..89214313 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/seeds/document.seed.ts @@ -0,0 +1,38 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import type { EntityData } from '@mikro-orm/core'; +import { AclContext } from '../acl.seed'; +import { DocumentFactory } from '../factory'; +import { DocumentAcl } from '../../../entities'; + +export class DocumentSeed extends Seeder { + async run(em: EntityManager, context: AclContext): Promise { + const documentFactory = new DocumentFactory(em); + const alice = context.aclContext.users.find(user => user.login === 'alice')!; + const bob = context.aclContext.users.find(user => user.login === 'bob')!; + const charlie = context.aclContext.users.find(user => user.login === 'charlie')!; + const admin = context.aclContext.users.find(user => user.login === 'admin')!; + const moderator = context.aclContext.users.find(user => user.login === 'moderator')!; + + const documentData: EntityData[] = [{ + owner: alice, + sharedWith: [bob.id, charlie.id], // ACL test: shared access + },{ + owner: admin, + isPublic: true, // ACL test: public document (anyone can read) + },{ + owner: bob, + }, { + owner: moderator, + sharedWith: [admin.id, alice.id], + }, { + owner: charlie, + sharedWith: [alice.id] + }] + + const count = documentData.length; + context.aclContext.documents = await documentFactory + .each((document) => Object.assign(document, documentData.shift())) + .create(count); + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/seeds/index.ts b/libs/microorm-database/src/lib/seeders/acl/seeds/index.ts new file mode 100644 index 00000000..7bcec93b --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/seeds/index.ts @@ -0,0 +1,7 @@ +export * from './user.seed' +export * from './category.seed' +export * from './post.seed' +export * from './tag.seed' +export * from './comment.seed' +export * from './article.seed' +export * from './document.seed' diff --git a/libs/microorm-database/src/lib/seeders/acl/seeds/post.seed.ts b/libs/microorm-database/src/lib/seeders/acl/seeds/post.seed.ts new file mode 100644 index 00000000..3c4a4bfd --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/seeds/post.seed.ts @@ -0,0 +1,109 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { AclContext } from '../acl.seed'; +import { PostFactory } from '../factory'; +import { PostAcl, PostStatus, TagAcl } from '../../../entities'; +import type { EntityData } from '@mikro-orm/core'; + +export class PostSeed extends Seeder { + async run(em: EntityManager, context: AclContext): Promise { + const postFactory = new PostFactory(em); + const alice = context.aclContext.users.find( + (user) => user.login === 'alice' + )!; + const bob = context.aclContext.users.find((user) => user.login === 'bob')!; + const charlie = context.aclContext.users.find( + (user) => user.login === 'charlie' + )!; + const webDevCategory = context.aclContext.categories.find( + (category) => category.slug === 'web-development' + )!; + const aiCategory = context.aclContext.categories.find( + (category) => category.slug === 'artificial-intelligence' + )!; + const healthCategory = context.aclContext.categories.find( + (category) => category.slug === 'health-fitness' + )!; + const techCategory = context.aclContext.categories.find( + (category) => category.slug === 'technology' + )!; + + const tagNestJS = context.aclContext.tags.find( + (tag) => tag.slug === 'nestjs' + )!; + const tagTypeScript = context.aclContext.tags.find( + (tag) => tag.slug === 'typescript' + )!; + const tagUserCreated = context.aclContext.tags.find( + (tag) => tag.slug === 'tutorial' + )!; + const tagMachineLearning = context.aclContext.tags.find( + (tag) => tag.slug === 'machine-learning' + )!; + const tagHealthy = context.aclContext.tags.find( + (tag) => tag.slug === 'healthy-living' + )!; + const postData: EntityData[] = [ + { + title: 'Getting Started with NestJS', + author: alice, + tags: [tagNestJS, tagTypeScript, tagUserCreated], + category: webDevCategory, + }, + { + title: 'Machine Learning Basics', + author: alice, + tags: [tagMachineLearning], + category: aiCategory, + }, + { + title: 'My Draft Post', + author: alice, + tags: [tagNestJS], + category: webDevCategory, + status: PostStatus.DRAFT, // ACL test: only author/admin can see + isPublished: false, + publishedAt: null, + viewCount: 0, + }, + { + title: 'Healthy Living Tips', + author: bob, + tags: [tagHealthy], + category: healthCategory, + }, + { + title: "Bob's Private Draft", + excerpt: null, + author: bob, + category: healthCategory, + status: PostStatus.DRAFT, + isPublished: false, + publishedAt: null, + viewCount: 0, + }, + { + title: 'TypeScript Best Practices', + author: charlie, + category: webDevCategory, + tags: [tagTypeScript, tagUserCreated], + }, + { + title: 'Archived Post', + author: charlie, + category: techCategory, + status: PostStatus.ARCHIVED, // ACL test: archived posts + isPublished: false, + }, + ]; + const count = postData.length; + + context.aclContext.posts = await postFactory + .each((post) => { + const { tags, ...other } = postData.shift()!; + Object.assign(post, other); + post.tags.set(tags as TagAcl[]); + }) + .create(count); + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/seeds/tag.seed.ts b/libs/microorm-database/src/lib/seeders/acl/seeds/tag.seed.ts new file mode 100644 index 00000000..23d991d0 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/seeds/tag.seed.ts @@ -0,0 +1,52 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { AclContext } from '../acl.seed'; +import { TagFactory } from '../factory'; +import { TagAcl } from '../../../entities'; +import type { EntityData } from '@mikro-orm/core'; + +export class TagSeed extends Seeder { + async run(em: EntityManager, context: AclContext): Promise { + const tagFactory = new TagFactory(em); + const admin = context.aclContext.users.find(user => user.login === 'admin')!; + const moderator = context.aclContext.users.find(user => user.login === 'moderator')!; + const alice = context.aclContext.users.find(user => user.login === 'alice')!; + const tagData: EntityData[] = [{ + name: 'NestJS', + slug: 'nestjs', + description: 'NestJS framework', + createdBy: admin, + isOfficial: true, + }, { + name: 'TypeScript', + slug: 'typescript', + description: 'TypeScript language', + createdBy: admin, + isOfficial: true, + }, { + name: 'Machine Learning', + slug: 'machine-learning', + description: 'ML and AI', + createdBy: admin, + isOfficial: true, + },{ + name: 'Healthy Living', + slug: 'healthy-living', + description: 'Health and wellness', + createdBy: moderator + }, { + name: 'Tutorial', + slug: 'tutorial', + description: 'Tutorial posts', + createdBy: alice, + isOfficial: false, // ACL test: user-created tags + }] + + const count = tagData.length; + context.aclContext.tags = await tagFactory + .each((tag) => Object.assign(tag, tagData.shift())) + .create(count); + + + } +} diff --git a/libs/microorm-database/src/lib/seeders/acl/seeds/user.seed.ts b/libs/microorm-database/src/lib/seeders/acl/seeds/user.seed.ts new file mode 100644 index 00000000..a7108616 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/acl/seeds/user.seed.ts @@ -0,0 +1,46 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { UserProfileFactory, UsersAclFactory } from '../factory'; +import { UserRole, UserProfileAcl, UsersAcl } from '../../../entities'; +import type { EntityData } from '@mikro-orm/core'; +import { AclContext } from '../acl.seed'; + +export class UsersSeed extends Seeder { + async run(em: EntityManager, context: AclContext) { + const userFactory = new UsersAclFactory(em); + const profileFactory = new UserProfileFactory(em); + + const usersArray: string[] = [ + 'inactive', + 'alice', + 'bob', + 'charlie', + 'admin', + 'moderator', + ]; + + const profileData: EntityData[] = [ + ...new Array(4).fill(null).map((_, i, array) => ({ + role: UserRole.user, + isPublic: i !== array.length - 1, + })), + { role: UserRole.admin }, + { role: UserRole.moderator }, + ]; + const count = profileData.length; + let activeUser = false; + context.aclContext.users = await userFactory + .each((user) => { + user.login = usersArray.shift() as string; + user.profile = profileFactory.makeOne({ + ...profileData.shift(), + ...(user.login === 'bob' ? { isPublic: false } : { }), + firstName: user.firstName, + lastName: user.lastName, + }); + user.isActive = activeUser; + activeUser = !activeUser; + }) + .create(count); + } +} diff --git a/libs/microorm-database/src/lib/seeders/api/api.seed.ts b/libs/microorm-database/src/lib/seeders/api/api.seed.ts new file mode 100644 index 00000000..939f44cc --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/api.seed.ts @@ -0,0 +1,26 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { Context } from '../database.seeder'; +import { Roles } from '../../entities'; +import { RolesSeed, UsersSeed } from './seeds'; + +export type InnerApiContext = { + roles: Roles[]; +}; + +export type ApiContext = Context & { apiContext: InnerApiContext }; + +export class ApiSeeder extends Seeder { + async run(em: EntityManager, context: Context = {}): Promise { + const innerContext: InnerApiContext = { + roles: [], + }; + const apiContext = { + ...context, + apiContext: innerContext, + }; + + await this.call(em, [RolesSeed], apiContext); + await this.call(em, [UsersSeed], apiContext); + } +} diff --git a/libs/microorm-database/src/lib/seeders/api/factory/addresses.factory.ts b/libs/microorm-database/src/lib/seeders/api/factory/addresses.factory.ts new file mode 100644 index 00000000..70c14ceb --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/factory/addresses.factory.ts @@ -0,0 +1,15 @@ +import { Factory } from '@mikro-orm/seeder'; +import { Addresses } from '../../../entities'; +import { faker } from '@faker-js/faker'; + +export class AddressesFactory extends Factory { + model = Addresses; + + definition(): Partial { + return { + city: faker.location.city(), + state: faker.location.state(), + country: faker.location.country(), + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/api/factory/book-list.factory.ts b/libs/microorm-database/src/lib/seeders/api/factory/book-list.factory.ts new file mode 100644 index 00000000..b3291cdc --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/factory/book-list.factory.ts @@ -0,0 +1,13 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker } from '@faker-js/faker'; +import { BookList } from '../../../entities'; + +export class BookListFactory extends Factory { + model = BookList; + + definition(): Partial { + return { + text: faker.book.title(), + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/api/factory/comments.factory.ts b/libs/microorm-database/src/lib/seeders/api/factory/comments.factory.ts new file mode 100644 index 00000000..048a0400 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/factory/comments.factory.ts @@ -0,0 +1,16 @@ +import { Factory } from '@mikro-orm/seeder'; +import { Comments } from '../../../entities'; + +import { faker } from '@faker-js/faker'; +import { CommentKind } from '@nestjs-json-api/typeorm-database'; + +export class CommentsFactory extends Factory { + model = Comments; + + definition(): Partial { + return { + kind: CommentKind.Comment, + text: faker.lorem.paragraph(faker.number.int(5)), + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/api/factory/index.ts b/libs/microorm-database/src/lib/seeders/api/factory/index.ts new file mode 100644 index 00000000..a8d418ff --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/factory/index.ts @@ -0,0 +1,5 @@ +export * from './user.factory' +export * from './addresses.factory' +export * from './book-list.factory' +export * from './roles.factory' +export * from './comments.factory'; diff --git a/libs/microorm-database/src/lib/seeders/api/factory/roles.factory.ts b/libs/microorm-database/src/lib/seeders/api/factory/roles.factory.ts new file mode 100644 index 00000000..97e78e43 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/factory/roles.factory.ts @@ -0,0 +1,17 @@ +import { Factory } from '@mikro-orm/seeder'; +import { Roles } from '../../../entities'; + +export class RolesFactory extends Factory { + model = Roles; + protected rolesList = ['USERS', 'ADMIN', 'OTHER']; + definition(): Partial { + const role = this.rolesList.shift(); + if (!role) { + throw new Error('Role is empty'); + } + return { + name: role.toLowerCase(), + key: role, + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/api/factory/user.factory.ts b/libs/microorm-database/src/lib/seeders/api/factory/user.factory.ts new file mode 100644 index 00000000..f93dd351 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/factory/user.factory.ts @@ -0,0 +1,24 @@ +import { Factory } from '@mikro-orm/seeder'; +import { faker, Sex } from '@faker-js/faker'; +import { Users } from '../../../entities'; + +export class UsersFactory extends Factory { + model = Users; + protected genderList: Record = { + [0]: Sex.Male, + [1]: Sex.Female, + }; + + definition(): Partial { + const gender: Sex = this.genderList[faker.number.int(1)]; + + const firstName = faker.person.firstName(gender); + const lastName = faker.person.lastName(gender); + return { + login: faker.internet.username({ firstName, lastName }), + firstName: firstName, + lastName: lastName, + isActive: faker.datatype.boolean(), + }; + } +} diff --git a/libs/microorm-database/src/lib/seeders/api/seeds/index.ts b/libs/microorm-database/src/lib/seeders/api/seeds/index.ts new file mode 100644 index 00000000..d9a70d5d --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/seeds/index.ts @@ -0,0 +1,2 @@ +export * from './user.seed'; +export * from './roles.seed'; diff --git a/libs/microorm-database/src/lib/seeders/api/seeds/roles.seed.ts b/libs/microorm-database/src/lib/seeders/api/seeds/roles.seed.ts new file mode 100644 index 00000000..5d3df765 --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/seeds/roles.seed.ts @@ -0,0 +1,11 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { ApiContext } from '../api.seed'; + +import { RolesFactory } from '../factory'; + +export class RolesSeed extends Seeder { + async run(em: EntityManager, context: ApiContext) { + context.apiContext.roles = await new RolesFactory(em).create(3); + } +} diff --git a/libs/microorm-database/src/lib/seeders/api/seeds/user.seed.ts b/libs/microorm-database/src/lib/seeders/api/seeds/user.seed.ts new file mode 100644 index 00000000..e74e70ef --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/api/seeds/user.seed.ts @@ -0,0 +1,59 @@ +import { Seeder } from '@mikro-orm/seeder'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { ApiContext } from '../api.seed'; + +import { + UsersFactory, + AddressesFactory, + BookListFactory, + CommentsFactory, +} from '../factory'; +import { faker } from '@faker-js/faker'; + +export class UsersSeed extends Seeder { + async run(em: EntityManager, context: ApiContext) { + const { roles } = context.apiContext; + + const manager = await new UsersFactory(em) + .each((user) => { + user.addresses = new AddressesFactory(em).makeOne(); + user.roles.set( + faker.helpers.arrayElements(roles, { + min: 1, + max: roles.length, + }) + ); + user.login = 'manager:' + user.login; + user.books.set(new BookListFactory(em).make(3)); + }) + .create(3); + + await new UsersFactory(em) + .each((user) => { + user.addresses = new AddressesFactory(em).makeOne(); + user.roles.set( + faker.helpers.arrayElements(roles, { + min: 1, + max: roles.length, + }) + ); + user.books.set(new BookListFactory(em).make(3)); + user.manager = manager.shift()!; + }) + .create(manager.length); + await new UsersFactory(em) + .each((user) => { + user.addresses = new AddressesFactory(em).makeOne(); + user.roles.set( + faker.helpers.arrayElements(roles, { + min: 1, + max: roles.length, + }) + ); + user.login = 'without-manager:' + user.login; + user.books.set(new BookListFactory(em).make(3)); + user.comments.set(new CommentsFactory(em).make(3)); + }) + .create(manager.length); + } +} diff --git a/libs/microorm-database/src/lib/seeders/database.seeder.ts b/libs/microorm-database/src/lib/seeders/database.seeder.ts new file mode 100644 index 00000000..a7e1229a --- /dev/null +++ b/libs/microorm-database/src/lib/seeders/database.seeder.ts @@ -0,0 +1,16 @@ +import type { EntityManager } from '@mikro-orm/postgresql'; +import { Seeder } from '@mikro-orm/seeder'; + + +import {AclSeed} from './acl/acl.seed'; +import {ApiSeeder} from './api/api.seed'; + +export type Context = {}; + +export class DatabaseSeeder extends Seeder { + async run(em: EntityManager, context: Context = {}): Promise { + await new ApiSeeder().run(em, context) + await new AclSeed().run(em, context) + + } +} diff --git a/libs/microorm-database/tsconfig.json b/libs/microorm-database/tsconfig.json index 6f7169a3..f1f23c01 100644 --- a/libs/microorm-database/tsconfig.json +++ b/libs/microorm-database/tsconfig.json @@ -7,7 +7,9 @@ "noImplicitOverride": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "noPropertyAccessFromIndexSignature": true + "noPropertyAccessFromIndexSignature": true, + "esModuleInterop": true, + "moduleResolution": "node16" }, "files": [], "include": [], diff --git a/libs/typeorm-database/src/lib/config-cli.ts b/libs/typeorm-database/src/lib/config-cli.ts index b2709439..7c624852 100644 --- a/libs/typeorm-database/src/lib/config-cli.ts +++ b/libs/typeorm-database/src/lib/config-cli.ts @@ -1,41 +1,9 @@ -import { DataSource, DataSourceOptions } from 'typeorm'; -import { join } from 'path'; -import * as process from 'process'; +import { DataSource } from 'typeorm'; -const isTest = process.env['NODE_ENV'] === 'test' || process.env['VITEST'] === 'true'; +import { join } from 'node:path'; +import { pgConfig } from './config-db'; -const configPg: DataSourceOptions = { - type: process.env['DB_TYPE'] as 'mysql' | 'postgres', - host: process.env['DB_HOST'], - port: parseInt(`${process.env['DB_PORT']}`, 10), - username: process.env['DB_USERNAME'], - password: process.env['DB_PASSWORD'], - database: process.env['DB_NAME'], - logging: process.env['DB_LOGGING'] === '1', - migrations: isTest ? [] : [join(__dirname, '/migrations/**/*{.ts,.js}')], - entities: [join(__dirname, '/entities/**/*{.ts,.js}')], - ...(process.env['DB_TYPE'] === 'mysql' ? { connectorPackage: 'mysql2' } : {}), -}; - -const configMysql: DataSourceOptions = { - type: process.env['DB_TYPE'] as 'mysql' | 'postgres', - host: process.env['DB_HOST'], - port: parseInt(`${process.env['DB_PORT']}`, 10), - username: process.env['DB_USERNAME'], - password: process.env['DB_PASSWORD'], - database: process.env['DB_NAME'], - logging: process.env['DB_LOGGING'] === '1', - migrations: isTest ? [] : [join(__dirname, '/migrations-mysql/**/*{.ts,.js}')], - entities: [join(__dirname, '/entities-mysql/**/*{.ts,.js}')], - connectorPackage: 'mysql2', -}; - -const configSeeder = { - seeders: ['./libs/database/src/lib/seeders/*.ts'], - defaultSeeder: 'RootSeeder', -}; - -const config = process.env['DB_TYPE'] === 'mysql' ? configMysql : configPg; -export { config, configSeeder }; - -export default new DataSource({ ...config, ...configSeeder }); +export default new DataSource({ + ...pgConfig, + migrations: [join(__dirname, '/migrations-pg/**/*{.ts,.js}')], +}); diff --git a/libs/typeorm-database/src/lib/config-db/index.ts b/libs/typeorm-database/src/lib/config-db/index.ts new file mode 100644 index 00000000..cc98c0ed --- /dev/null +++ b/libs/typeorm-database/src/lib/config-db/index.ts @@ -0,0 +1 @@ +export * from './pg-config' diff --git a/libs/typeorm-database/src/lib/config-db/pg-config.ts b/libs/typeorm-database/src/lib/config-db/pg-config.ts new file mode 100644 index 00000000..10e4a285 --- /dev/null +++ b/libs/typeorm-database/src/lib/config-db/pg-config.ts @@ -0,0 +1,24 @@ +import { PGliteDriver } from 'typeorm-pglite'; +import * as process from 'node:process'; +import { DataSourceOptions } from 'typeorm'; +import { mkdirSync } from 'node:fs'; +// @ts-ignore +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp'; +import * as allEntities from '../entities'; + +const pgDir = process.env['TEST'] ? './tmp/pg-test/typeorm' : './tmp/pg/typeorm' + +mkdirSync(pgDir, { recursive: true }); + + +const pgConfig: DataSourceOptions = { + type: 'postgres', + driver: new PGliteDriver({ + dataDir: pgDir, + extensions: { uuid_ossp }, + }).driver, + logging: process.env['DB_LOGGING'] === '1', + entities: Object.values(allEntities) as any, +}; + +export { pgConfig }; diff --git a/libs/typeorm-database/src/lib/config.ts b/libs/typeorm-database/src/lib/config.ts index 8945ed97..b9215c5c 100644 --- a/libs/typeorm-database/src/lib/config.ts +++ b/libs/typeorm-database/src/lib/config.ts @@ -1,11 +1,4 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { pgConfig } from './config-db'; -import { config as ormConfig } from './config-cli'; -import * as allEntities from './entities'; - -export const config: TypeOrmModuleOptions = { - ...ormConfig, - ...{ - entities: Object.values(allEntities) as any, - }, -}; +export const config: TypeOrmModuleOptions = pgConfig; diff --git a/libs/typeorm-database/src/lib/entities/acl-test/article.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/article.entity.ts new file mode 100644 index 00000000..b143ecd3 --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/article.entity.ts @@ -0,0 +1,164 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UsersAcl, IUsersAcl } from './user.entity'; + +export enum ArticleStatus { + DRAFT = 'draft', + REVIEW = 'review', + PUBLISHED = 'published', +} + +export enum ArticleVisibility { + PUBLIC = 'public', + PRIVATE = 'private', + UNLISTED = 'unlisted', +} + +export interface ArticleMetadata { + readTime: number; + featured: boolean; + premium: boolean; +} + +export type IArticleAcl = ArticleAcl; + +/** + * Article entity for ACL testing - Complex scenarios + * + * ACL Test Cases: + * - Multiple owners (authorId OR coAuthorIds.includes(userId)) + * - Array conditions (checking if userId in coAuthorIds array) + * - Nested object conditions (metadata.premium) + * - Time-based access (expiresAt > now) + * - Complex workflows (draft -> review -> published) + * - Editor role (separate from author) + * - Template: ${@input.coAuthorIds}, ${metadata.premium}, ${currentTime} + */ +@Entity('acl_articles') +export class ArticleAcl { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ + type: 'varchar', + length: 255, + nullable: false, + }) + public title!: string; + + @Column({ + type: 'text', + nullable: false, + }) + public content!: string; + + /** + * Primary author (ownership) + */ + @ManyToOne(() => UsersAcl, (user) => user.authoredArticles, { + nullable: false, + }) + @JoinColumn({ + name: 'author_id', + }) + public author!: IUsersAcl; + + /** + * Co-authors array for multiple ownership testing + * ACL: Check if currentUserId in this array + */ + @Column({ + name: 'co_author_ids', + type: 'int', + array: true, + nullable: false, + default: () => "'{}'", + }) + public coAuthorIds!: number[]; + + /** + * Editor (different from author/co-authors) + * Can edit but not delete + */ + @ManyToOne(() => UsersAcl, (user) => user.editedArticles, { + nullable: true, + }) + @JoinColumn({ + name: 'editor_id', + }) + public editor!: IUsersAcl | null; + + /** + * Workflow status + */ + @Column({ + type: 'enum', + enum: ArticleStatus, + default: ArticleStatus.DRAFT, + }) + public status!: ArticleStatus; + + /** + * Visibility control + */ + @Column({ + type: 'enum', + enum: ArticleVisibility, + default: ArticleVisibility.PUBLIC, + }) + public visibility!: ArticleVisibility; + + /** + * Metadata as JSON object + * ACL: Check nested properties like metadata.premium + */ + @Column({ + type: 'json', + nullable: false, + default: '{"readTime": 0, "featured": false, "premium": false}', + }) + public metadata!: ArticleMetadata; + + /** + * Publish date for time-based access + */ + @Column({ + name: 'published_at', + type: 'timestamp', + nullable: true, + }) + public publishedAt!: Date | null; + + /** + * Expiration date for temporary access + * ACL: Check if current time < expiresAt + */ + @Column({ + name: 'expires_at', + type: 'timestamp', + nullable: true, + }) + public expiresAt!: Date | null; + + @Column({ + name: 'created_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date = new Date(); + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/typeorm-database/src/lib/entities/acl-test/category.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/category.entity.ts new file mode 100644 index 00000000..f1e81fdc --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/category.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + JoinColumn, + UpdateDateColumn, +} from 'typeorm'; + +export type ICategoryAcl = CategoryAcl; + +/** + * Category entity for ACL testing + * Self-referencing hierarchical structure + * + * ACL Test Cases: + * - Hierarchical permissions (parent -> children access) + * - Self-referencing relationships + * - Depth-based access control (level) + * - Active/inactive categories + * - Template: ${@input.parentId}, ${category.parent.id} + */ +@Entity('acl_categories') +export class CategoryAcl { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ + type: 'varchar', + length: 100, + nullable: false, + }) + public name!: string; + + @Column({ + type: 'varchar', + length: 150, + nullable: false, + unique: true, + }) + public slug!: string; + + /** + * Self-referencing parent category + */ + @ManyToOne(() => CategoryAcl, (category) => category.children, { + nullable: true, + }) + @JoinColumn({ + name: 'parent_id', + }) + public parent!: CategoryAcl | null; + + /** + * Self-referencing children categories + */ + @OneToMany(() => CategoryAcl, (category) => category.parent) + public children!: CategoryAcl[]; + + /** + * Depth level in hierarchy + * 0 = root category, 1 = first level child, etc. + */ + @Column({ + type: 'integer', + default: 0, + }) + public level!: number; + + /** + * Active/inactive flag for access control + */ + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + public isActive!: boolean; + + @Column({ + type: 'text', + nullable: true, + }) + public description!: string | null; + + @Column({ + name: 'created_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date = new Date(); + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/typeorm-database/src/lib/entities/acl-test/comment.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/comment.entity.ts new file mode 100644 index 00000000..e1198bac --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/comment.entity.ts @@ -0,0 +1,92 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UsersAcl, IUsersAcl } from './user.entity'; +import { PostAcl } from './post.entity'; + +export type ICommentAcl = CommentAcl; + +/** + * Comment entity for ACL testing + * Many-to-One with Post and Users + * + * ACL Test Cases: + * - Nested ownership (comment.authorId === currentUserId) + * - Moderation (isApproved - only moderator/admin can approve) + * - Relationship chain (User -> Comment -> Post) + * - Template: ${@input.postId}, ${post.authorId} + */ +@Entity('acl_comments') +export class CommentAcl { + @PrimaryGeneratedColumn() + public id!: number; + + /** + * Post that this comment belongs to + */ + @ManyToOne(() => PostAcl, (post) => post.comments, { + nullable: false, + }) + @JoinColumn({ + name: 'post_id', + }) + public post!: PostAcl; + + /** + * Author of the comment (ownership) + */ + @ManyToOne(() => UsersAcl, (user) => user.aclComments, { + nullable: false, + }) + @JoinColumn({ + name: 'author_id', + }) + public author!: IUsersAcl; + + @Column({ + type: 'text', + nullable: false, + }) + public content!: string; + + /** + * Moderation flag - only moderator/admin can approve + */ + @Column({ + name: 'is_approved', + type: 'boolean', + default: false, + }) + public isApproved!: boolean; + + /** + * Edit flag to track if comment was modified + */ + @Column({ + name: 'is_edited', + type: 'boolean', + default: false, + }) + public isEdited!: boolean; + + @Column({ + name: 'created_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date = new Date(); + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/typeorm-database/src/lib/entities/acl-test/context.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/context.entity.ts new file mode 100644 index 00000000..c64487f0 --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/context.entity.ts @@ -0,0 +1,23 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +export type IContextTestAcl = ContextTestAcl; + +@Entity('acl_context_test') +export class ContextTestAcl { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ + type: 'json', + nullable: false, + default: '{}', + }) + aclRules!: { rules: Record[] }; + + @Column({ + type: 'json', + nullable: false, + default: '{}', + }) + context!: Record; +} diff --git a/libs/typeorm-database/src/lib/entities/acl-test/document.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/document.entity.ts new file mode 100644 index 00000000..63ee2690 --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/document.entity.ts @@ -0,0 +1,111 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UsersAcl, IUsersAcl } from './user.entity'; + +export type IDocumentAcl = DocumentAcl; + +/** + * Document entity for ACL testing - File upload scenarios + * + * ACL Test Cases: + * - Ownership (ownerId === currentUserId) + * - Shared access (sharedWith.includes(currentUserId)) + * - Public/private toggle (isPublic) + * - File-specific permissions (read, download, delete) + * - Template: ${@input.sharedWith}, ${currentUserId} + */ +@Entity('acl_documents') +export class DocumentAcl { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ + type: 'varchar', + length: 255, + nullable: false, + }) + public filename!: string; + + @Column({ + name: 'mime_type', + type: 'varchar', + length: 100, + nullable: false, + }) + public mimeType!: string; + + /** + * File size in bytes + */ + @Column({ + type: 'bigint', + nullable: false, + }) + public size!: number; + + /** + * File path/URL + */ + @Column({ + type: 'varchar', + length: 500, + nullable: false, + }) + public path!: string; + + /** + * Owner of the document + */ + @ManyToOne(() => UsersAcl, (user) => user.documents, { + nullable: false, + }) + @JoinColumn({ + name: 'owner_id', + }) + public owner!: IUsersAcl; + + /** + * Array of user IDs who have access to this document + * ACL: Check if currentUserId in this array + */ + @Column({ + name: 'shared_with', + type: 'simple-array', + nullable: false, + default: '', + }) + public sharedWith!: number[]; + + /** + * Public access flag + * If true, anyone can read (but not modify/delete) + */ + @Column({ + name: 'is_public', + type: 'boolean', + default: false, + }) + public isPublic!: boolean; + + @Column({ + name: 'uploaded_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + uploadedAt: Date = new Date(); + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/typeorm-database/src/lib/entities/acl-test/index.ts b/libs/typeorm-database/src/lib/entities/acl-test/index.ts new file mode 100644 index 00000000..b2fffd41 --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/index.ts @@ -0,0 +1,37 @@ +/** + * ACL Test Entities + * + * These entities are designed to test all aspects of the ACL permissions module. + * + * Entity Overview: + * - UserProfile: One-to-One with Users, field-level permissions + * - Category: Self-referencing hierarchical structure + * - Post: Core entity with status, ownership, relationships + * - Comment: Nested ownership, moderation + * - Tag: Many-to-Many with Post, creator ownership + * - Article: Complex scenarios (multiple owners, nested objects, time-based) + * - Document: File upload scenarios, shared access + * + * Coverage: + * ✅ Ownership patterns + * ✅ Role-based access + * ✅ Field-level permissions (Entity:select) + * ✅ Relationship permissions (Entity:include) + * ✅ Status-based access + * ✅ Hierarchical permissions + * ✅ Array conditions + * ✅ Nested object conditions + * ✅ Time-based access + * ✅ @context templates + * ✅ @input templates + */ + +export * from './user.entity'; +export * from './user-profile.entity'; +export * from './category.entity'; +export * from './post.entity'; +export * from './comment.entity'; +export * from './tag.entity'; +export * from './article.entity'; +export * from './document.entity'; +export * from './context.entity'; diff --git a/libs/typeorm-database/src/lib/entities/acl-test/post.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/post.entity.ts new file mode 100644 index 00000000..69cf989b --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/post.entity.ts @@ -0,0 +1,163 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + ManyToMany, + JoinColumn, + JoinTable, + UpdateDateColumn, +} from 'typeorm'; +import { UsersAcl, IUsersAcl } from './user.entity'; +import { CategoryAcl, ICategoryAcl } from './category.entity'; +import { TagAcl, ITagAcl } from './tag.entity'; +import { CommentAcl, ICommentAcl } from './comment.entity'; + +export enum PostStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + ARCHIVED = 'archived', +} + +export type IPostAcl = PostAcl; + +/** + * Post entity for ACL testing + * Many-to-One with Users (author) and Category + * Many-to-Many with Tag + * One-to-Many with Comment + * + * ACL Test Cases: + * - Ownership (authorId === currentUserId) + * - Status-based access (only published posts for guest) + * - Conditional access (owner OR admin OR moderator) + * - Field-level permissions (Post:select - viewCount only for author/admin) + * - Relationship permissions (Post:include - comments, tags) + * - Template: ${currentUserId}, ${@input.authorId}, ${@input.status} + */ +@Entity('acl_posts') +export class PostAcl { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ + type: 'varchar', + length: 255, + nullable: false, + }) + public title!: string; + + @Column({ + type: 'text', + nullable: false, + }) + public content!: string; + + @Column({ + type: 'text', + nullable: true, + }) + public excerpt!: string | null; + + /** + * Author of the post (ownership) + */ + @ManyToOne(() => UsersAcl, (user) => user.posts, { + nullable: false, + }) + @JoinColumn({ + name: 'author_id', + }) + public author!: IUsersAcl; + + /** + * Category for hierarchical permissions + */ + @ManyToOne(() => CategoryAcl, { + nullable: true, + }) + @JoinColumn({ + name: 'category_id', + }) + public category!: ICategoryAcl | null; + + /** + * Status for status-based access control + */ + @Column({ + type: 'enum', + enum: PostStatus, + default: PostStatus.DRAFT, + }) + public status!: PostStatus; + + /** + * Published flag (alternative to status) + */ + @Column({ + name: 'is_published', + type: 'boolean', + default: false, + }) + public isPublished!: boolean; + + /** + * Publish date for time-based access + */ + @Column({ + name: 'published_at', + type: 'timestamp', + nullable: true, + }) + public publishedAt!: Date | null; + + /** + * View count - private field (only author/admin can see) + */ + @Column({ + name: 'view_count', + type: 'integer', + default: 0, + }) + public viewCount!: number; + + /** + * Many-to-Many with Tags + */ + @ManyToMany(() => TagAcl, (tag) => tag.posts) + @JoinTable({ + name: 'acl_posts_tags', + joinColumn: { + name: 'post_acl_id', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'tag_acl_id', + referencedColumnName: 'id', + }, + }) + public tags!: ITagAcl[]; + + /** + * One-to-Many with Comments + */ + @OneToMany(() => CommentAcl, (comment) => comment.post) + public comments!: ICommentAcl[]; + + @Column({ + name: 'created_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date = new Date(); + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/typeorm-database/src/lib/entities/acl-test/tag.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/tag.entity.ts new file mode 100644 index 00000000..56ae903b --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/tag.entity.ts @@ -0,0 +1,94 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + ManyToMany, + JoinColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UsersAcl, IUsersAcl } from './user.entity'; +import { PostAcl } from './post.entity'; + +export type ITagAcl = TagAcl; + +/** + * Tag entity for ACL testing + * Many-to-Many relationship with Post + * + * ACL Test Cases: + * - Many-to-Many relationship (Post <-> Tag) + * - Creator ownership (createdById === currentUserId) + * - Privileged entities (isOfficial - only admin can create) + * - Template: ${currentUserId}, ${@input.createdById} + */ +@Entity('acl_tags') +export class TagAcl { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ + type: 'varchar', + length: 100, + nullable: false, + }) + public name!: string; + + @Column({ + type: 'varchar', + length: 150, + nullable: false, + unique: true, + }) + public slug!: string; + + /** + * User who created this tag + */ + @ManyToOne(() => UsersAcl, (user) => user.createdTags, { + nullable: false, + }) + @JoinColumn({ + name: 'created_by_id', + }) + public createdBy!: IUsersAcl; + + /** + * Official tags can only be created by admins + * Regular users can create non-official tags + */ + @Column({ + name: 'is_official', + type: 'boolean', + default: false, + }) + public isOfficial!: boolean; + + @Column({ + type: 'text', + nullable: true, + }) + public description!: string | null; + + /** + * Many-to-Many relationship with Post + */ + @ManyToMany(() => PostAcl, (post) => post.tags) + public posts!: PostAcl[]; + + @Column({ + name: 'created_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date = new Date(); + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/typeorm-database/src/lib/entities/acl-test/user-profile.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/user-profile.entity.ts new file mode 100644 index 00000000..0a50bc22 --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/user-profile.entity.ts @@ -0,0 +1,122 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + JoinColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UsersAcl, IUsersAcl } from './user.entity'; + +export type IUserProfileAcl = UserProfileAcl; + +export enum UserRole { + admin = 'admin', + user = 'user', + moderator = 'moderator', +} + +/** + * UserProfile entity for ACL testing + * One-to-One relationship with Users + * + * ACL Test Cases: + * - Field-level permissions (UserProfile:select) + * - Private fields: phone, salary (only owner or admin) + * - Public fields: firstName, lastName, bio, avatar + * - Privacy settings (isPublic) + */ +@Entity('acl_user_profiles') +export class UserProfileAcl { + @PrimaryGeneratedColumn() + public id!: number; + + @OneToOne(() => UsersAcl, (user) => user.profile) + @JoinColumn({ + name: 'user_id', + }) + public user!: IUsersAcl; + + @Column({ + name: 'first_name', + type: 'varchar', + length: 100, + nullable: true, + }) + public firstName!: string | null; + + @Column({ + name: 'last_name', + type: 'varchar', + length: 100, + nullable: true, + }) + public lastName!: string | null; + + @Column({ + type: 'text', + nullable: true, + }) + public bio!: string | null; + + @Column({ + type: 'varchar', + length: 255, + nullable: true, + }) + public avatar!: string | null; + + /** + * Private field - only owner or admin can see + */ + @Column({ + type: 'varchar', + length: 200, + nullable: true, + }) + public phone!: string | null; + + /** + * Private field - only owner or admin can see + */ + @Column({ + type: 'decimal', + precision: 10, + scale: 2, + nullable: true, + }) + public salary!: number | null; + + /** + * Privacy setting - if false, profile is private + */ + @Column({ + name: 'is_public', + type: 'boolean', + default: true, + }) + public isPublic!: boolean; + + @Column({ + type: 'enum', + enum: UserRole, + default: UserRole.user, + }) + public role!: UserRole; + + @Column({ + name: 'created_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date = new Date(); + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date = new Date(); +} diff --git a/libs/typeorm-database/src/lib/entities/acl-test/user.entity.ts b/libs/typeorm-database/src/lib/entities/acl-test/user.entity.ts new file mode 100644 index 00000000..93682c86 --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/acl-test/user.entity.ts @@ -0,0 +1,120 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + OneToMany, + UpdateDateColumn, +} from 'typeorm'; + +import { IUserProfileAcl, UserProfileAcl } from './user-profile.entity'; +import { IPostAcl, PostAcl } from './post.entity'; +import { CommentAcl, ICommentAcl } from './comment.entity'; +import { TagAcl, ITagAcl } from './tag.entity'; +import { ArticleAcl, IArticleAcl } from './article.entity'; +import { DocumentAcl, IDocumentAcl } from './document.entity'; + +export type IUsersAcl = UsersAcl; + +@Entity('acl_users') +export class UsersAcl { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ + type: 'varchar', + length: 100, + nullable: false, + unique: true, + }) + public login!: string; + + @Column({ + name: 'first_name', + type: 'varchar', + length: 100, + nullable: true, + default: 'NULL', + }) + public firstName!: string; + + @Column({ + name: 'last_name', + type: 'varchar', + length: 100, + nullable: true, + default: 'NULL', + }) + public lastName!: string; + + @Column({ + name: 'is_active', + type: 'boolean', + nullable: true, + default: false, + }) + public isActive!: boolean; + + @Column({ + name: 'created_at', + type: 'timestamp', + nullable: true, + default: () => 'CURRENT_TIMESTAMP', + }) + createdAt: Date = new Date(); + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date = new Date(); + + // ======================================== + // ACL Test Entities Relationships + // ======================================== + + /** + * One-to-One reverse relationship with UserProfile + * User has one profile + */ + @OneToOne(() => UserProfileAcl, (profile) => profile.user) + public profile!: IUserProfileAcl; + + /** + * One-to-Many: User authored posts + */ + @OneToMany(() => PostAcl, (post) => post.author) + public posts!: IPostAcl[]; + + /** + * One-to-Many: User authored comments + */ + @OneToMany(() => CommentAcl, (comment) => comment.author) + public aclComments!: ICommentAcl[]; + + /** + * One-to-Many: Tags created by user + */ + @OneToMany(() => TagAcl, (tag) => tag.createdBy) + public createdTags!: ITagAcl[]; + + /** + * One-to-Many: Articles authored by user + */ + @OneToMany(() => ArticleAcl, (article) => article.author) + public authoredArticles!: IArticleAcl[]; + + /** + * One-to-Many: Articles edited by user + */ + @OneToMany(() => ArticleAcl, (article) => article.editor) + public editedArticles!: IArticleAcl[]; + + /** + * One-to-Many: Documents owned by user + */ + @OneToMany(() => DocumentAcl, (document) => document.owner) + public documents!: IDocumentAcl[]; +} diff --git a/libs/typeorm-database/src/lib/entities/addresses.ts b/libs/typeorm-database/src/lib/entities/addresses.ts index 484e041e..fda5cba3 100644 --- a/libs/typeorm-database/src/lib/entities/addresses.ts +++ b/libs/typeorm-database/src/lib/entities/addresses.ts @@ -43,7 +43,7 @@ export class Addresses { name: 'created_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public createdAt!: Date; @@ -51,7 +51,7 @@ export class Addresses { name: 'updated_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public updatedAt!: Date; diff --git a/libs/typeorm-database/src/lib/entities/book-list.ts b/libs/typeorm-database/src/lib/entities/book-list.ts index 31131e63..b7e4c03f 100644 --- a/libs/typeorm-database/src/lib/entities/book-list.ts +++ b/libs/typeorm-database/src/lib/entities/book-list.ts @@ -12,7 +12,9 @@ export type IBookList = BookList; @Entity('book_list') export class BookList { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn('uuid', { + name: 'id', + }) public id!: string; @Column({ @@ -25,7 +27,7 @@ export class BookList { name: 'created_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public createdAt!: Date; @@ -33,7 +35,7 @@ export class BookList { name: 'updated_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public updatedAt!: Date; diff --git a/libs/typeorm-database/src/lib/entities/comment-acl.ts b/libs/typeorm-database/src/lib/entities/comment-acl.ts new file mode 100644 index 00000000..942c4e7b --- /dev/null +++ b/libs/typeorm-database/src/lib/entities/comment-acl.ts @@ -0,0 +1,58 @@ +import { + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + Entity, + Column, + CreateDateColumn, +} from 'typeorm'; + +import { PostACL, IPostACL, UserACL, IUserACL } from '.'; + +export type ICommentACL = CommentACL; + +@Entity('comment_acl') +export class CommentACL { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ + type: 'text', + nullable: false, + }) + public text!: string; + + @Column({ + name: 'post_id', + type: 'int', + nullable: false, + }) + public postId!: number; + + @ManyToOne(() => PostACL, (post) => post.comments) + @JoinColumn({ + name: 'post_id', + }) + public post!: IPostACL; + + @Column({ + name: 'author_id', + type: 'int', + nullable: false, + }) + public authorId!: number; + + @ManyToOne(() => UserACL, (user) => user.comments) + @JoinColumn({ + name: 'author_id', + }) + public author!: IUserACL; + + @CreateDateColumn({ + name: 'created_at', + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt!: Date; +} \ No newline at end of file diff --git a/libs/typeorm-database/src/lib/entities/comments.ts b/libs/typeorm-database/src/lib/entities/comments.ts index 51ff0e83..595d1b59 100644 --- a/libs/typeorm-database/src/lib/entities/comments.ts +++ b/libs/typeorm-database/src/lib/entities/comments.ts @@ -39,7 +39,7 @@ export class Comments { name: 'created_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public createdAt!: Date; @@ -47,7 +47,7 @@ export class Comments { name: 'updated_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public updatedAt!: Date; diff --git a/libs/typeorm-database/src/lib/entities/index.ts b/libs/typeorm-database/src/lib/entities/index.ts index 35a21523..69f52470 100644 --- a/libs/typeorm-database/src/lib/entities/index.ts +++ b/libs/typeorm-database/src/lib/entities/index.ts @@ -2,5 +2,8 @@ export * from './addresses'; export * from './roles'; export * from './users'; export * from './comments'; -export * from './users-have-roles'; +// export * from './users-have-roles'; export * from './book-list'; + +// ACL test entities +export * from './acl-test'; diff --git a/libs/typeorm-database/src/lib/entities/roles.ts b/libs/typeorm-database/src/lib/entities/roles.ts index 98e7b151..5e4256e8 100644 --- a/libs/typeorm-database/src/lib/entities/roles.ts +++ b/libs/typeorm-database/src/lib/entities/roles.ts @@ -34,7 +34,7 @@ export class Roles { @Column({ name: 'is_default', type: 'boolean', - default: 'false', + default: () => 'false', }) public isDefault!: boolean; @@ -42,7 +42,7 @@ export class Roles { name: 'created_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public createdAt!: Date; @@ -50,7 +50,7 @@ export class Roles { name: 'updated_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public updatedAt!: Date; diff --git a/libs/typeorm-database/src/lib/entities/users.ts b/libs/typeorm-database/src/lib/entities/users.ts index a709101e..1baae0b4 100644 --- a/libs/typeorm-database/src/lib/entities/users.ts +++ b/libs/typeorm-database/src/lib/entities/users.ts @@ -67,7 +67,7 @@ export class Users { name: 'created_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public createdAt!: Date; @@ -75,7 +75,7 @@ export class Users { name: 'updated_at', type: 'timestamp', nullable: true, - default: 'CURRENT_TIMESTAMP', + default: () => 'CURRENT_TIMESTAMP', }) public updatedAt!: Date; diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760952388797-addresses.ts b/libs/typeorm-database/src/lib/migrations-pg/1760952388797-addresses.ts new file mode 100644 index 00000000..c1e81bf8 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760952388797-addresses.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Addresses1760952388797 implements MigrationInterface { + name = 'Addresses1760952388797' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "addresses" ("id" SERIAL NOT NULL, "city" character varying(70) DEFAULT 'NULL', "state" character varying(70) DEFAULT 'NULL', "country" character varying(68) DEFAULT 'NULL', "created_at" TIMESTAMP DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), CONSTRAINT "PK_745d8f43d3af10ab8247465e450" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "addresses"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760952504762-users.ts b/libs/typeorm-database/src/lib/migrations-pg/1760952504762-users.ts new file mode 100644 index 00000000..56b729af --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760952504762-users.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Users1760952504762 implements MigrationInterface { + name = 'Users1760952504762' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "users" ("id" SERIAL NOT NULL, "login" character varying(100) NOT NULL, "first_name" character varying(100) DEFAULT 'NULL', "last_name" character varying(100) DEFAULT 'NULL', "is_active" boolean DEFAULT false, "created_at" TIMESTAMP DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), CONSTRAINT "UQ_2d443082eccd5198f95f2a36e2c" UNIQUE ("login"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "users"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760952591019-users-addresses.ts b/libs/typeorm-database/src/lib/migrations-pg/1760952591019-users-addresses.ts new file mode 100644 index 00000000..4b089343 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760952591019-users-addresses.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UsersAddresses1760952591019 implements MigrationInterface { + name = 'UsersAddresses1760952591019' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "addresses_id" integer`); + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_2f8d527df0d3acb8aa51945a968" UNIQUE ("addresses_id")`); + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "FK_2f8d527df0d3acb8aa51945a968" FOREIGN KEY ("addresses_id") REFERENCES "addresses"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_2f8d527df0d3acb8aa51945a968"`); + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_2f8d527df0d3acb8aa51945a968"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "addresses_id"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760952682444-roles.ts b/libs/typeorm-database/src/lib/migrations-pg/1760952682444-roles.ts new file mode 100644 index 00000000..64d1d6e7 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760952682444-roles.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Roles1760952682444 implements MigrationInterface { + name = 'Roles1760952682444' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "roles" ("id" SERIAL NOT NULL, "name" character varying(128) DEFAULT 'NULL', "key" character varying(128) NOT NULL, "is_default" boolean NOT NULL DEFAULT 'false', "created_at" TIMESTAMP DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), CONSTRAINT "UQ_a87cf0659c3ac379b339acf36a2" UNIQUE ("key"), CONSTRAINT "PK_c1433d71a4838793a49dcad46ab" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "roles"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760952747208-users-roles.ts b/libs/typeorm-database/src/lib/migrations-pg/1760952747208-users-roles.ts new file mode 100644 index 00000000..1343d97b --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760952747208-users-roles.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UsersRoles1760952747208 implements MigrationInterface { + name = 'UsersRoles1760952747208' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "users_have_roles" ("users_id" integer NOT NULL, "roles_id" integer NOT NULL, CONSTRAINT "PK_45abc98688f8c198420048741b8" PRIMARY KEY ("users_id", "roles_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_bb66594b23503c32a315a63fe1" ON "users_have_roles" ("users_id") `); + await queryRunner.query(`CREATE INDEX "IDX_afa6ce5ffd14d2e5580fd54624" ON "users_have_roles" ("roles_id") `); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT 'false'`); + await queryRunner.query(`ALTER TABLE "users_have_roles" ADD CONSTRAINT "FK_bb66594b23503c32a315a63fe1e" FOREIGN KEY ("users_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "users_have_roles" ADD CONSTRAINT "FK_afa6ce5ffd14d2e5580fd54624e" FOREIGN KEY ("roles_id") REFERENCES "roles"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users_have_roles" DROP CONSTRAINT "FK_afa6ce5ffd14d2e5580fd54624e"`); + await queryRunner.query(`ALTER TABLE "users_have_roles" DROP CONSTRAINT "FK_bb66594b23503c32a315a63fe1e"`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT false`); + await queryRunner.query(`DROP INDEX "public"."IDX_afa6ce5ffd14d2e5580fd54624"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bb66594b23503c32a315a63fe1"`); + await queryRunner.query(`DROP TABLE "users_have_roles"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760952883251-comments.ts b/libs/typeorm-database/src/lib/migrations-pg/1760952883251-comments.ts new file mode 100644 index 00000000..2cedae0c --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760952883251-comments.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Comments1760952883251 implements MigrationInterface { + name = 'Comments1760952883251' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."comments_kind_enum" AS ENUM('COMMENT', 'MESSAGE', 'NOTE')`); + await queryRunner.query(`CREATE TABLE "comments" ("id" SERIAL NOT NULL, "text" text NOT NULL, "kind" "public"."comments_kind_enum" NOT NULL, "created_at" TIMESTAMP DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), CONSTRAINT "PK_8bf68bc960f2b69e818bdb90dcb" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT 'false'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT false`); + await queryRunner.query(`DROP TABLE "comments"`); + await queryRunner.query(`DROP TYPE "public"."comments_kind_enum"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760952932505-users-comments.ts b/libs/typeorm-database/src/lib/migrations-pg/1760952932505-users-comments.ts new file mode 100644 index 00000000..7a170f1d --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760952932505-users-comments.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UsersComments1760952932505 implements MigrationInterface { + name = 'UsersComments1760952932505' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "comments" ADD "created_by" integer`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT 'false'`); + await queryRunner.query(`ALTER TABLE "comments" ADD CONSTRAINT "FK_980bfefe00ed11685f325d0bd4c" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "comments" DROP CONSTRAINT "FK_980bfefe00ed11685f325d0bd4c"`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT false`); + await queryRunner.query(`ALTER TABLE "comments" DROP COLUMN "created_by"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760953013026-book-list.ts b/libs/typeorm-database/src/lib/migrations-pg/1760953013026-book-list.ts new file mode 100644 index 00000000..57c2826c --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760953013026-book-list.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class BookList1760953013026 implements MigrationInterface { + name = 'BookList1760953013026' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "book_list" ("id" SERIAL NOT NULL, "text" text NOT NULL, "created_at" TIMESTAMP DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), CONSTRAINT "PK_8cf4bf655b0ec86d610e471641d" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT 'false'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT false`); + await queryRunner.query(`DROP TABLE "book_list"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760953076052-users-book-list.ts b/libs/typeorm-database/src/lib/migrations-pg/1760953076052-users-book-list.ts new file mode 100644 index 00000000..8421cbf1 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760953076052-users-book-list.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UsersBookList1760953076052 implements MigrationInterface { + name = 'UsersBookList1760953076052' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "users_have_book" ("users_id" integer NOT NULL, "book_list_id" integer NOT NULL, CONSTRAINT "PK_38e99ac4899701872062fa4c5cb" PRIMARY KEY ("users_id", "book_list_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_7b85c8711c8d45d686a1b6ea64" ON "users_have_book" ("users_id") `); + await queryRunner.query(`CREATE INDEX "IDX_88eb6d4c67dab296d402b0ae2e" ON "users_have_book" ("book_list_id") `); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT 'false'`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD CONSTRAINT "FK_7b85c8711c8d45d686a1b6ea64e" FOREIGN KEY ("users_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD CONSTRAINT "FK_88eb6d4c67dab296d402b0ae2e2" FOREIGN KEY ("book_list_id") REFERENCES "book_list"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users_have_book" DROP CONSTRAINT "FK_88eb6d4c67dab296d402b0ae2e2"`); + await queryRunner.query(`ALTER TABLE "users_have_book" DROP CONSTRAINT "FK_7b85c8711c8d45d686a1b6ea64e"`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT false`); + await queryRunner.query(`DROP INDEX "public"."IDX_88eb6d4c67dab296d402b0ae2e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7b85c8711c8d45d686a1b6ea64"`); + await queryRunner.query(`DROP TABLE "users_have_book"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760953100013-users-manager.ts b/libs/typeorm-database/src/lib/migrations-pg/1760953100013-users-manager.ts new file mode 100644 index 00000000..31b87bff --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760953100013-users-manager.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UsersManager1760953100013 implements MigrationInterface { + name = 'UsersManager1760953100013' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "manager_id" integer`); + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_fba2d8e029689aa8fea98e53c91" UNIQUE ("manager_id")`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT 'false'`); + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "FK_fba2d8e029689aa8fea98e53c91" FOREIGN KEY ("manager_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_fba2d8e029689aa8fea98e53c91"`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT false`); + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_fba2d8e029689aa8fea98e53c91"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "manager_id"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1760959428979-book-list-uuid.ts b/libs/typeorm-database/src/lib/migrations-pg/1760959428979-book-list-uuid.ts new file mode 100644 index 00000000..f8a85dca --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1760959428979-book-list-uuid.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class BookListUuid1760959428979 implements MigrationInterface { + name = 'BookListUuid1760959428979' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT 'false'`); + await queryRunner.query(`ALTER TABLE "users_have_book" DROP CONSTRAINT "FK_88eb6d4c67dab296d402b0ae2e2"`); + await queryRunner.query(`ALTER TABLE "book_list" DROP CONSTRAINT "PK_8cf4bf655b0ec86d610e471641d"`); + await queryRunner.query(`ALTER TABLE "book_list" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "book_list" ADD "id" uuid NOT NULL DEFAULT uuid_generate_v4()`); + await queryRunner.query(`ALTER TABLE "book_list" ADD CONSTRAINT "PK_8cf4bf655b0ec86d610e471641d" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "users_have_book" DROP CONSTRAINT "PK_38e99ac4899701872062fa4c5cb"`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD CONSTRAINT "PK_7b85c8711c8d45d686a1b6ea64e" PRIMARY KEY ("users_id")`); + await queryRunner.query(`DROP INDEX "public"."IDX_88eb6d4c67dab296d402b0ae2e"`); + await queryRunner.query(`ALTER TABLE "users_have_book" DROP COLUMN "book_list_id"`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD "book_list_id" uuid NOT NULL`); + await queryRunner.query(`ALTER TABLE "users_have_book" DROP CONSTRAINT "PK_7b85c8711c8d45d686a1b6ea64e"`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD CONSTRAINT "PK_38e99ac4899701872062fa4c5cb" PRIMARY KEY ("users_id", "book_list_id")`); + await queryRunner.query(`CREATE INDEX "IDX_88eb6d4c67dab296d402b0ae2e" ON "users_have_book" ("book_list_id") `); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD CONSTRAINT "FK_88eb6d4c67dab296d402b0ae2e2" FOREIGN KEY ("book_list_id") REFERENCES "book_list"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users_have_book" DROP CONSTRAINT "FK_88eb6d4c67dab296d402b0ae2e2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_88eb6d4c67dab296d402b0ae2e"`); + await queryRunner.query(`ALTER TABLE "users_have_book" DROP CONSTRAINT "PK_38e99ac4899701872062fa4c5cb"`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD CONSTRAINT "PK_7b85c8711c8d45d686a1b6ea64e" PRIMARY KEY ("users_id")`); + await queryRunner.query(`ALTER TABLE "users_have_book" DROP COLUMN "book_list_id"`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD "book_list_id" integer NOT NULL`); + await queryRunner.query(`CREATE INDEX "IDX_88eb6d4c67dab296d402b0ae2e" ON "users_have_book" ("book_list_id") `); + await queryRunner.query(`ALTER TABLE "users_have_book" DROP CONSTRAINT "PK_7b85c8711c8d45d686a1b6ea64e"`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD CONSTRAINT "PK_38e99ac4899701872062fa4c5cb" PRIMARY KEY ("book_list_id", "users_id")`); + await queryRunner.query(`ALTER TABLE "book_list" DROP CONSTRAINT "PK_8cf4bf655b0ec86d610e471641d"`); + await queryRunner.query(`ALTER TABLE "book_list" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "book_list" ADD "id" SERIAL NOT NULL`); + await queryRunner.query(`ALTER TABLE "book_list" ADD CONSTRAINT "PK_8cf4bf655b0ec86d610e471641d" PRIMARY KEY ("id")`); + await queryRunner.query(`ALTER TABLE "users_have_book" ADD CONSTRAINT "FK_88eb6d4c67dab296d402b0ae2e2" FOREIGN KEY ("book_list_id") REFERENCES "book_list"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "roles" ALTER COLUMN "is_default" SET DEFAULT false`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762922721853-create-user-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762922721853-create-user-acl.ts new file mode 100644 index 00000000..baa81b34 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762922721853-create-user-acl.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateUserAcl1762922721853 implements MigrationInterface { + name = 'CreateUserAcl1762922721853' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "acl_users" ("id" SERIAL NOT NULL, "login" character varying(100) NOT NULL, "first_name" character varying(100) DEFAULT 'NULL', "last_name" character varying(100) DEFAULT 'NULL', "is_active" boolean DEFAULT false, "created_at" TIMESTAMP DEFAULT now(), "updated_at" TIMESTAMP DEFAULT now(), CONSTRAINT "UQ_c8fcd14d1f189ce9b442f8334af" UNIQUE ("login"), CONSTRAINT "PK_2e01ce6e61175f84520187152ca" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "acl_users"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762922799242-create-user-profile-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762922799242-create-user-profile-acl.ts new file mode 100644 index 00000000..90510777 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762922799242-create-user-profile-acl.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateUserProfileAcl1762922799242 implements MigrationInterface { + name = 'CreateUserProfileAcl1762922799242' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."acl_user_profiles_role_enum" AS ENUM('admin', 'user', 'moderator')`); + await queryRunner.query(`CREATE TABLE "acl_user_profiles" ("id" SERIAL NOT NULL, "first_name" character varying(100), "last_name" character varying(100), "bio" text, "avatar" character varying(255), "phone" character varying(200), "salary" numeric(10,2), "is_public" boolean NOT NULL DEFAULT true, "role" "public"."acl_user_profiles_role_enum" NOT NULL DEFAULT 'user', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" integer, CONSTRAINT "REL_78b082233fc8ab9308a77739d3" UNIQUE ("user_id"), CONSTRAINT "PK_b8deca153edfaa849436166c261" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "acl_user_profiles" ADD CONSTRAINT "FK_78b082233fc8ab9308a77739d31" FOREIGN KEY ("user_id") REFERENCES "acl_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "acl_user_profiles" DROP CONSTRAINT "FK_78b082233fc8ab9308a77739d31"`); + await queryRunner.query(`DROP TABLE "acl_user_profiles"`); + await queryRunner.query(`DROP TYPE "public"."acl_user_profiles_role_enum"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762922867959-category-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762922867959-category-acl.ts new file mode 100644 index 00000000..be3be078 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762922867959-category-acl.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CategoryAcl1762922867959 implements MigrationInterface { + name = 'CategoryAcl1762922867959' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "acl_categories" ("id" SERIAL NOT NULL, "name" character varying(100) NOT NULL, "slug" character varying(150) NOT NULL, "level" integer NOT NULL DEFAULT '0', "is_active" boolean NOT NULL DEFAULT true, "description" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "parent_id" integer, CONSTRAINT "UQ_003552626673e2b1b35fee30344" UNIQUE ("slug"), CONSTRAINT "PK_a05e0032ca25208457dd595f4f4" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "acl_categories" ADD CONSTRAINT "FK_e6d3276c516d6b930ed19e87b58" FOREIGN KEY ("parent_id") REFERENCES "acl_categories"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "acl_categories" DROP CONSTRAINT "FK_e6d3276c516d6b930ed19e87b58"`); + await queryRunner.query(`DROP TABLE "acl_categories"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762922980161-posts-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762922980161-posts-acl.ts new file mode 100644 index 00000000..bef5d638 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762922980161-posts-acl.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class PostsAcl1762922980161 implements MigrationInterface { + name = 'PostsAcl1762922980161' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."acl_posts_status_enum" AS ENUM('draft', 'published', 'archived')`); + await queryRunner.query(`CREATE TABLE "acl_posts" ("id" SERIAL NOT NULL, "title" character varying(255) NOT NULL, "content" text NOT NULL, "excerpt" text, "status" "public"."acl_posts_status_enum" NOT NULL DEFAULT 'draft', "is_published" boolean NOT NULL DEFAULT false, "published_at" TIMESTAMP, "view_count" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "author_id" integer NOT NULL, "category_id" integer, CONSTRAINT "PK_f71a42818c5da397f1b18b33ca7" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "acl_posts" ADD CONSTRAINT "FK_49095f8d8388105d54529ead932" FOREIGN KEY ("author_id") REFERENCES "acl_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "acl_posts" ADD CONSTRAINT "FK_24fc3ee66b45e3d6eb1dd0a1204" FOREIGN KEY ("category_id") REFERENCES "acl_categories"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "acl_posts" DROP CONSTRAINT "FK_24fc3ee66b45e3d6eb1dd0a1204"`); + await queryRunner.query(`ALTER TABLE "acl_posts" DROP CONSTRAINT "FK_49095f8d8388105d54529ead932"`); + await queryRunner.query(`DROP TABLE "acl_posts"`); + await queryRunner.query(`DROP TYPE "public"."acl_posts_status_enum"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762923198202-comments-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762923198202-comments-acl.ts new file mode 100644 index 00000000..7e349990 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762923198202-comments-acl.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CommentsAcl1762923198202 implements MigrationInterface { + name = 'CommentsAcl1762923198202' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "acl_comments" ("id" SERIAL NOT NULL, "content" text NOT NULL, "is_approved" boolean NOT NULL DEFAULT false, "is_edited" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "post_id" integer NOT NULL, "author_id" integer NOT NULL, CONSTRAINT "PK_ca277117c8248a61e4cc368f3e0" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "acl_comments" ADD CONSTRAINT "FK_61e92badc2df53e8b09f2db7a6b" FOREIGN KEY ("post_id") REFERENCES "acl_posts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "acl_comments" ADD CONSTRAINT "FK_9e8669c52a560b44eb92d00e8b6" FOREIGN KEY ("author_id") REFERENCES "acl_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "acl_comments" DROP CONSTRAINT "FK_9e8669c52a560b44eb92d00e8b6"`); + await queryRunner.query(`ALTER TABLE "acl_comments" DROP CONSTRAINT "FK_61e92badc2df53e8b09f2db7a6b"`); + await queryRunner.query(`DROP TABLE "acl_comments"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762923285020-tags-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762923285020-tags-acl.ts new file mode 100644 index 00000000..6ec7a757 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762923285020-tags-acl.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class TagsAcl1762923285020 implements MigrationInterface { + name = 'TagsAcl1762923285020' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "acl_tags" ("id" SERIAL NOT NULL, "name" character varying(100) NOT NULL, "slug" character varying(150) NOT NULL, "is_official" boolean NOT NULL DEFAULT false, "description" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "created_by_id" integer NOT NULL, CONSTRAINT "UQ_cb73dec75e13f6ca0cc53c7e119" UNIQUE ("slug"), CONSTRAINT "PK_0cd59c8ea9b729cb8bbadd0f1c1" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "acl_posts_tags" ("post_acl_id" integer NOT NULL, "tag_acl_id" integer NOT NULL, CONSTRAINT "PK_976ceb7a24b7a17f7d99c038197" PRIMARY KEY ("post_acl_id", "tag_acl_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_bbf500a1b01d7109b7a0b2bdcc" ON "acl_posts_tags" ("post_acl_id") `); + await queryRunner.query(`CREATE INDEX "IDX_5b2cc57430f349c4bb4867f66e" ON "acl_posts_tags" ("tag_acl_id") `); + await queryRunner.query(`ALTER TABLE "acl_tags" ADD CONSTRAINT "FK_d1c608d057ce31dfea67a9a7049" FOREIGN KEY ("created_by_id") REFERENCES "acl_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "acl_posts_tags" ADD CONSTRAINT "FK_bbf500a1b01d7109b7a0b2bdcc4" FOREIGN KEY ("post_acl_id") REFERENCES "acl_posts"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "acl_posts_tags" ADD CONSTRAINT "FK_5b2cc57430f349c4bb4867f66ed" FOREIGN KEY ("tag_acl_id") REFERENCES "acl_tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "acl_posts_tags" DROP CONSTRAINT "FK_5b2cc57430f349c4bb4867f66ed"`); + await queryRunner.query(`ALTER TABLE "acl_posts_tags" DROP CONSTRAINT "FK_bbf500a1b01d7109b7a0b2bdcc4"`); + await queryRunner.query(`ALTER TABLE "acl_tags" DROP CONSTRAINT "FK_d1c608d057ce31dfea67a9a7049"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5b2cc57430f349c4bb4867f66e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bbf500a1b01d7109b7a0b2bdcc"`); + await queryRunner.query(`DROP TABLE "acl_posts_tags"`); + await queryRunner.query(`DROP TABLE "acl_tags"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762923805711-article-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762923805711-article-acl.ts new file mode 100644 index 00000000..174bdc60 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762923805711-article-acl.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ArticleAcl1762923805711 implements MigrationInterface { + name = 'ArticleAcl1762923805711' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."acl_articles_status_enum" AS ENUM('draft', 'review', 'published')`); + await queryRunner.query(`CREATE TYPE "public"."acl_articles_visibility_enum" AS ENUM('public', 'private', 'unlisted')`); + await queryRunner.query(`CREATE TABLE "acl_articles" ("id" SERIAL NOT NULL, "title" character varying(255) NOT NULL, "content" text NOT NULL, "co_author_ids" integer array NOT NULL DEFAULT '{}', "status" "public"."acl_articles_status_enum" NOT NULL DEFAULT 'draft', "visibility" "public"."acl_articles_visibility_enum" NOT NULL DEFAULT 'public', "metadata" json NOT NULL DEFAULT '{"readTime": 0, "featured": false, "premium": false}', "published_at" TIMESTAMP, "expires_at" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "author_id" integer NOT NULL, "editor_id" integer, CONSTRAINT "PK_a8b46052028c40ef71a8d0dec46" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "acl_articles" ADD CONSTRAINT "FK_d533de0d25e82f813452d6b1a00" FOREIGN KEY ("author_id") REFERENCES "acl_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "acl_articles" ADD CONSTRAINT "FK_737a198380679073248993f08b8" FOREIGN KEY ("editor_id") REFERENCES "acl_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "acl_articles" DROP CONSTRAINT "FK_737a198380679073248993f08b8"`); + await queryRunner.query(`ALTER TABLE "acl_articles" DROP CONSTRAINT "FK_d533de0d25e82f813452d6b1a00"`); + await queryRunner.query(`DROP TABLE "acl_articles"`); + await queryRunner.query(`DROP TYPE "public"."acl_articles_visibility_enum"`); + await queryRunner.query(`DROP TYPE "public"."acl_articles_status_enum"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762923848507-docuemnt-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762923848507-docuemnt-acl.ts new file mode 100644 index 00000000..3de2dc4b --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762923848507-docuemnt-acl.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DocuemntAcl1762923848507 implements MigrationInterface { + name = 'DocuemntAcl1762923848507' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "acl_documents" ("id" SERIAL NOT NULL, "filename" character varying(255) NOT NULL, "mime_type" character varying(100) NOT NULL, "size" bigint NOT NULL, "path" character varying(500) NOT NULL, "shared_with" text NOT NULL DEFAULT '', "is_public" boolean NOT NULL DEFAULT false, "uploaded_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" integer NOT NULL, CONSTRAINT "PK_87a342ec07408de4abbfe0926d6" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "acl_documents" ADD CONSTRAINT "FK_cf140746d213640ad4b3a2fe8cc" FOREIGN KEY ("owner_id") REFERENCES "acl_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "acl_documents" DROP CONSTRAINT "FK_cf140746d213640ad4b3a2fe8cc"`); + await queryRunner.query(`DROP TABLE "acl_documents"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations-pg/1762941166039-context-acl.ts b/libs/typeorm-database/src/lib/migrations-pg/1762941166039-context-acl.ts new file mode 100644 index 00000000..e433afd8 --- /dev/null +++ b/libs/typeorm-database/src/lib/migrations-pg/1762941166039-context-acl.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ContextAcl1762941166039 implements MigrationInterface { + name = 'ContextAcl1762941166039' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "acl_context_test" ("id" SERIAL NOT NULL, "aclRules" json NOT NULL DEFAULT '{}', "context" json NOT NULL DEFAULT '{}', CONSTRAINT "PK_12da682d534824e0fcbbd1cb585" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "acl_context_test"`); + } + +} diff --git a/libs/typeorm-database/src/lib/migrations/1607701631900-CreateAddressesTable.ts b/libs/typeorm-database/src/lib/migrations/1607701631900-CreateAddressesTable.ts deleted file mode 100644 index 986e2eab..00000000 --- a/libs/typeorm-database/src/lib/migrations/1607701631900-CreateAddressesTable.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { MigrationInterface, QueryRunner, TableColumn, Table } from 'typeorm'; - -export class CreateAddressesTable1607701631900 implements MigrationInterface { - protected readonly tableName = 'addresses'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.createTable( - new Table({ - name: this.tableName, - columns: [ - new TableColumn({ - name: 'id', - type: 'int', - isGenerated: true, - isPrimary: true, - unsigned: true, - generationStrategy: 'increment', - }), - new TableColumn({ - name: 'city', - type: 'varchar', - length: '70', - isNullable: true, - default: 'NULL', - }), - new TableColumn({ - name: 'state', - type: 'varchar', - length: '70', - isNullable: true, - default: 'NULL', - }), - new TableColumn({ - name: 'country', - type: 'varchar', - length: '70', - isNullable: true, - default: 'NULL', - }), - new TableColumn({ - name: 'created_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - new TableColumn({ - name: 'updated_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - ], - }) - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(this.tableName); - } -} diff --git a/libs/typeorm-database/src/lib/migrations/1607701632000-CreateUsersTable.ts b/libs/typeorm-database/src/lib/migrations/1607701632000-CreateUsersTable.ts deleted file mode 100644 index f69f76e2..00000000 --- a/libs/typeorm-database/src/lib/migrations/1607701632000-CreateUsersTable.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - MigrationInterface, - TableForeignKey, - QueryRunner, - TableColumn, - Table, -} from 'typeorm'; - -export class CreateUsersTable1607701632000 implements MigrationInterface { - protected readonly tableName = 'users'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.createTable( - new Table({ - name: this.tableName, - columns: [ - new TableColumn({ - name: 'id', - type: 'int', - isGenerated: true, - isPrimary: true, - unsigned: true, - generationStrategy: 'increment', - }), - new TableColumn({ - name: 'login', - type: 'varchar', - length: '100', - isNullable: false, - isUnique: true, - }), - new TableColumn({ - name: 'first_name', - type: 'varchar', - isNullable: true, - default: 'NULL', - }), - new TableColumn({ - name: 'last_name', - type: 'varchar', - isNullable: true, - default: 'NULL', - }), - new TableColumn({ - name: 'is_active', - type: 'boolean', - width: 1, - isNullable: true, - default: false, - }), - new TableColumn({ - name: 'manager_id', - type: 'int', - isNullable: true, - unsigned: true, - default: 'NULL', - }), - new TableColumn({ - name: 'addresses_id', - type: 'int', - isNullable: false, - unsigned: true, - }), - new TableColumn({ - name: 'created_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - new TableColumn({ - name: 'updated_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - ], - foreignKeys: [ - new TableForeignKey({ - referencedTableName: 'addresses', - referencedColumnNames: ['id'], - columnNames: ['addresses_id'], - }), - new TableForeignKey({ - referencedTableName: 'users', - referencedColumnNames: ['id'], - columnNames: ['manager_id'], - }), - ], - }) - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(this.tableName); - } -} diff --git a/libs/typeorm-database/src/lib/migrations/1607701632200-CreateRolesTable.ts b/libs/typeorm-database/src/lib/migrations/1607701632200-CreateRolesTable.ts deleted file mode 100644 index 06d1d21e..00000000 --- a/libs/typeorm-database/src/lib/migrations/1607701632200-CreateRolesTable.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { MigrationInterface, QueryRunner, TableColumn, Table } from 'typeorm'; -import { Roles } from '../entities'; - -export enum RoleKeys { - ReadOnly = 'READ_ONLY', - User = 'USER', - Administrator = 'ADMINISTRATOR', -} - -export class CreateRolesTable1607701632200 implements MigrationInterface { - protected readonly tableName = 'roles'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.createTable( - new Table({ - name: this.tableName, - columns: [ - new TableColumn({ - name: 'id', - type: 'int', - isGenerated: true, - isPrimary: true, - unsigned: true, - generationStrategy: 'increment', - }), - new TableColumn({ - name: 'name', - type: 'varchar', - length: '128', - isNullable: true, - default: 'NULL', - }), - new TableColumn({ - name: 'key', - type: 'varchar', - length: '128', - isNullable: false, - isUnique: true, - }), - new TableColumn({ - name: 'is_default', - type: 'boolean', - width: 1, - isNullable: true, - default: false, - }), - new TableColumn({ - name: 'created_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - new TableColumn({ - name: 'updated_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - ], - }) - ); - - const repo = queryRunner.manager.getRepository(Roles); - const roles = { - readOnly: new Roles(), - user: new Roles(), - admin: new Roles(), - }; - - roles.readOnly.name = 'Read Only'; - roles.readOnly.key = RoleKeys.ReadOnly; - - roles.user.name = 'User'; - roles.user.key = RoleKeys.User; - roles.user.isDefault = true; - - roles.admin.name = 'Administrator'; - roles.admin.key = RoleKeys.Administrator; - - await repo.save(Object.values(roles), { reload: false }); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(this.tableName); - } -} diff --git a/libs/typeorm-database/src/lib/migrations/1607701632300-CreateUsersHaveRolesTable.ts b/libs/typeorm-database/src/lib/migrations/1607701632300-CreateUsersHaveRolesTable.ts deleted file mode 100644 index 6c0f068e..00000000 --- a/libs/typeorm-database/src/lib/migrations/1607701632300-CreateUsersHaveRolesTable.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - MigrationInterface, - TableForeignKey, - QueryRunner, - TableColumn, - Table, - TableIndex, -} from 'typeorm'; - -export class CreateUsersHaveRolesTable1607701632300 - implements MigrationInterface -{ - protected readonly tableName = 'users_have_roles'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.createTable( - new Table({ - name: this.tableName, - columns: [ - new TableColumn({ - name: 'id', - type: 'int', - isGenerated: true, - isPrimary: true, - unsigned: true, - generationStrategy: 'increment', - }), - new TableColumn({ - name: 'users_id', - type: 'int', - isNullable: false, - unsigned: true, - }), - new TableColumn({ - name: 'roles_id', - type: 'int', - isNullable: false, - unsigned: true, - }), - new TableColumn({ - name: 'created_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - new TableColumn({ - name: 'updated_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - ], - foreignKeys: [ - new TableForeignKey({ - referencedTableName: 'users', - referencedColumnNames: ['id'], - columnNames: ['users_id'], - }), - new TableForeignKey({ - referencedTableName: 'roles', - referencedColumnNames: ['id'], - columnNames: ['roles_id'], - }), - ], - indices: [ - new TableIndex({ - columnNames: ['users_id', 'roles_id'], - isUnique: true, - }), - ], - }) - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(this.tableName); - } -} diff --git a/libs/typeorm-database/src/lib/migrations/1607701632600-CreateCommentsTable.ts b/libs/typeorm-database/src/lib/migrations/1607701632600-CreateCommentsTable.ts deleted file mode 100644 index 869d9a5e..00000000 --- a/libs/typeorm-database/src/lib/migrations/1607701632600-CreateCommentsTable.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - MigrationInterface, - QueryRunner, - TableColumn, - Table, - TableForeignKey, -} from 'typeorm'; - -export class CreateCommentsTable1607701632600 implements MigrationInterface { - protected readonly tableName = 'comments'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE "comment_kind_enum" AS ENUM('COMMENT', 'MESSAGE', 'NOTE') - `); - - await queryRunner.createTable( - new Table({ - name: this.tableName, - columns: [ - new TableColumn({ - name: 'id', - type: 'int', - isGenerated: true, - isPrimary: true, - unsigned: true, - generationStrategy: 'increment', - }), - new TableColumn({ - name: 'text', - type: 'text', - isNullable: false, - }), - new TableColumn({ - name: 'kind', - type: 'comment_kind_enum', - isNullable: false, - }), - new TableColumn({ - name: 'created_by', - type: 'int', - isNullable: true, - unsigned: true, - }), - new TableColumn({ - name: 'created_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - new TableColumn({ - name: 'updated_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - ], - foreignKeys: [ - new TableForeignKey({ - referencedTableName: 'users', - referencedColumnNames: ['id'], - columnNames: ['created_by'], - }), - ], - }) - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(this.tableName); - await queryRunner.query('DROP TYPE IF EXISTS comment_kind_enum'); - } -} diff --git a/libs/typeorm-database/src/lib/migrations/1665469071344-CreateBookTable.ts b/libs/typeorm-database/src/lib/migrations/1665469071344-CreateBookTable.ts deleted file mode 100644 index b3fc4033..00000000 --- a/libs/typeorm-database/src/lib/migrations/1665469071344-CreateBookTable.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; - -export class CreateCommentsTable1665469071344 implements MigrationInterface { - protected readonly tableName = 'book_list'; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE EXTENSION IF NOT EXISTS "uuid-ossp" - `); - await queryRunner.createTable( - new Table({ - name: this.tableName, - columns: [ - new TableColumn({ - name: 'id', - type: 'uuid', - isGenerated: true, - isPrimary: true, - unsigned: true, - generationStrategy: 'uuid', - }), - new TableColumn({ - name: 'text', - type: 'text', - isNullable: false, - }), - new TableColumn({ - name: 'created_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - new TableColumn({ - name: 'updated_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - ], - }) - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(this.tableName); - } -} diff --git a/libs/typeorm-database/src/lib/migrations/1665719467563-CreateUsersHasBookTable.ts b/libs/typeorm-database/src/lib/migrations/1665719467563-CreateUsersHasBookTable.ts deleted file mode 100644 index d91ac132..00000000 --- a/libs/typeorm-database/src/lib/migrations/1665719467563-CreateUsersHasBookTable.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - MigrationInterface, - TableForeignKey, - QueryRunner, - TableColumn, - Table, - TableIndex, -} from 'typeorm'; - -export class CreateUsersHaveBookTable1665719467563 - implements MigrationInterface -{ - protected readonly tableName = 'users_have_book'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.createTable( - new Table({ - name: this.tableName, - columns: [ - new TableColumn({ - name: 'id', - type: 'int', - isGenerated: true, - isPrimary: true, - unsigned: true, - generationStrategy: 'increment', - }), - new TableColumn({ - name: 'users_id', - type: 'int', - isNullable: false, - unsigned: true, - }), - new TableColumn({ - name: 'book_list_id', - type: 'uuid', - isNullable: false, - unsigned: true, - }), - new TableColumn({ - name: 'created_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - new TableColumn({ - name: 'updated_at', - type: 'timestamp', - isNullable: true, - default: 'CURRENT_TIMESTAMP', - }), - ], - foreignKeys: [ - new TableForeignKey({ - referencedTableName: 'users', - referencedColumnNames: ['id'], - columnNames: ['users_id'], - }), - new TableForeignKey({ - referencedTableName: 'book_list', - referencedColumnNames: ['id'], - columnNames: ['book_list_id'], - }), - ], - indices: [ - new TableIndex({ - columnNames: ['users_id', 'book_list_id'], - isUnique: true, - }), - ], - }) - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable(this.tableName); - } -} diff --git a/libs/typeorm-database/src/lib/seeders/acl/acl.seed.ts b/libs/typeorm-database/src/lib/seeders/acl/acl.seed.ts new file mode 100644 index 00000000..16a36831 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/acl.seed.ts @@ -0,0 +1,82 @@ +import { + CategoryAcl, + UsersAcl, + PostAcl, + TagAcl, + CommentAcl, + ArticleAcl, + DocumentAcl, +} from '../../entities/acl-test'; + +import { Context } from '../database.seeder'; + +export type InnerAclContext = { + users: UsersAcl[]; + categories: CategoryAcl[]; + posts: PostAcl[]; + tags: TagAcl[]; + comments: CommentAcl[]; + articles: ArticleAcl[]; + documents: DocumentAcl[]; +}; + +export type AclContext = Context & { aclContext: InnerAclContext }; + +import { + UsersSeed, + CategorySeed, + PostSeed, + TagSeed, + CommentSeed, + DocumentSeed, + ArticleSeed, +} from './seeds'; +import { BaseSeeder } from '../base-seeder'; +import { DataSource } from 'typeorm'; + +/** + * ACL Test Data Seeder + * + * Creates comprehensive test data for all ACL permission scenarios: + * - Ownership patterns (author, owner, creator) + * - Role-based access (admin, moderator, user) + * - Status-based filtering (draft, published, archived) + * - Field-level permissions (private fields like phone, salary) + * - Relationship permissions (includes, joins) + * - Array conditions (co-authors, shared users) + * - Time-based access (expires_at) + */ +export class AclSeed extends BaseSeeder { + async run(dataSource: DataSource, context: Context = {}): Promise { + const innerContext: InnerAclContext = { + users: [], + categories: [], + posts: [], + tags: [], + comments: [], + articles: [], + documents: [], + }; + const aclContext = { + ...context, + aclContext: innerContext, + }; + await this.call(dataSource, [UsersSeed, CategorySeed], aclContext); + await this.call(dataSource, [TagSeed], aclContext); + await this.call(dataSource, [PostSeed], aclContext); + await this.call(dataSource, [CommentSeed, DocumentSeed, ArticleSeed], aclContext); + + console.log('✅ ACL seed data created successfully!'); + console.log(` +📊 Created: + - 6 Users (admin, moderator, alice, bob, charlie, inactive) + - 6 UserProfiles (public/private) + - 6 Categories (hierarchical structure) + - 7 Posts (published/draft/archived) + - 5 Tags (official/user-created) + - 7 Comments (approved/pending) + - 5 Articles (complex scenarios) + - 5 Documents (public/shared/private) + `); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/article.factory.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/article.factory.ts new file mode 100644 index 00000000..99647bac --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/article.factory.ts @@ -0,0 +1,27 @@ +import { faker } from '@faker-js/faker'; +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; + +import { ArticleAcl, ArticleStatus, ArticleVisibility } from '../../../entities/acl-test'; +import { BaseFactory } from '../../base-factory'; + +export class ArticleFactory extends BaseFactory { + entity = ArticleAcl; + + protected attrs(): FactorizedAttrs { + return { + title: faker.lorem.sentence(), + content: faker.lorem.paragraphs(8), + coAuthorIds: [], + status: ArticleStatus.PUBLISHED, + visibility: ArticleVisibility.PUBLIC, + editor: null, + metadata: { + readTime: faker.number.int({ min: 5, max: 30 }), + featured: true, + premium: false, + }, + publishedAt: faker.date.past(), + expiresAt: null, + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/category.factory.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/category.factory.ts new file mode 100644 index 00000000..085e2a2d --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/category.factory.ts @@ -0,0 +1,20 @@ +import { faker } from '@faker-js/faker'; +import { CategoryAcl } from '../../../entities/acl-test'; +import { BaseFactory } from '../../base-factory'; +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; + +export class CategoryFactory extends BaseFactory { + entity = CategoryAcl; + + protected attrs(): FactorizedAttrs { + const name = faker.word.words(2); + return { + name, + slug: faker.helpers.slugify(name).toLowerCase(), + description: faker.lorem.sentence(), + parent: null, + level: 0, + isActive: true, + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/comment.factory.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/comment.factory.ts new file mode 100644 index 00000000..5b33ca48 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/comment.factory.ts @@ -0,0 +1,17 @@ +import { faker } from '@faker-js/faker'; +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; +import { CommentAcl } from '../../../entities/acl-test'; +import { BaseFactory } from '../../base-factory'; + + +export class CommentFactory extends BaseFactory { + entity = CommentAcl; + + protected attrs(): FactorizedAttrs { + return { + content: faker.lorem.paragraph(), + isApproved: true, + isEdited: false, + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/document.factory.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/document.factory.ts new file mode 100644 index 00000000..ab755e00 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/document.factory.ts @@ -0,0 +1,35 @@ +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; +import { faker } from '@faker-js/faker'; +import { DocumentAcl } from '../../../entities/acl-test'; +import { BaseFactory } from '../../base-factory'; + + +export class DocumentFactory extends BaseFactory { + entity = DocumentAcl; + + protected attrs(): FactorizedAttrs { + const extensions = [ + { ext: 'pdf', mime: 'application/pdf' }, + { ext: 'jpg', mime: 'image/jpeg' }, + { ext: 'png', mime: 'image/png' }, + { ext: 'docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + { ext: 'xlsx', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + { ext: 'txt', mime: 'text/plain' }, + { ext: 'zip', mime: 'application/zip' }, + { ext: 'mp4', mime: 'video/mp4' }, + { ext: 'mp3', mime: 'audio/mpeg' }, + ]; + + const fileType = faker.helpers.arrayElement(extensions); + const filename = `${faker.word.noun()}-${faker.string.alphanumeric(8)}.${fileType.ext}`; + const dirPath = faker.system.directoryPath(); + return { + filename: filename, + mimeType: fileType.mime, + size: faker.number.int({ min: 1000, max: 10000000 }), + path: `/uploads${dirPath}/${filename}`, + sharedWith: [], + isPublic: false, + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/index.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/index.ts new file mode 100644 index 00000000..141221c8 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/index.ts @@ -0,0 +1,8 @@ +export * from './user.factory'; +export * from './user-profile.factory'; +export * from './category.factory'; +export * from './post.factory'; +export * from './tag.factory'; +export * from './comment.factory'; +export * from './article.factory'; +export * from './document.factory'; diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/post.factory.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/post.factory.ts new file mode 100644 index 00000000..b8372bd5 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/post.factory.ts @@ -0,0 +1,22 @@ +import { faker } from '@faker-js/faker'; +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; + + +import { PostAcl, PostStatus } from '../../../entities/acl-test'; +import { BaseFactory } from '../../base-factory'; + +export class PostFactory extends BaseFactory { + entity = PostAcl; + + protected attrs(): FactorizedAttrs { + return { + title: faker.lorem.sentence(), + content: faker.lorem.paragraphs(5), + excerpt: faker.lorem.paragraph(), + status: PostStatus.PUBLISHED, + isPublished: true, + publishedAt: faker.date.past(), + viewCount: faker.number.int({ min: 0, max: 5000 }), + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/tag.factory.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/tag.factory.ts new file mode 100644 index 00000000..85889771 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/tag.factory.ts @@ -0,0 +1,19 @@ +import { faker } from '@faker-js/faker'; +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; + +import { TagAcl } from '../../../entities/acl-test'; +import { BaseFactory } from '../../base-factory'; + +export class TagFactory extends BaseFactory { + entity = TagAcl; + + protected attrs(): FactorizedAttrs { + const name = faker.word.noun(); + return { + name, + slug: faker.helpers.slugify(name).toLowerCase(), + description: faker.lorem.sentence(), + isOfficial: false, + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/user-profile.factory.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/user-profile.factory.ts new file mode 100644 index 00000000..0068b976 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/user-profile.factory.ts @@ -0,0 +1,22 @@ +import { faker } from '@faker-js/faker'; +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; + +import { UserProfileAcl } from '../../../entities/acl-test'; +import { BaseFactory } from '../../base-factory'; + + +export class UserProfileFactory extends BaseFactory { + entity = UserProfileAcl; + + protected attrs(): FactorizedAttrs { + return { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + bio: faker.lorem.sentence(), + avatar: faker.image.avatar(), + phone: faker.phone.number(), + salary: faker.number.int({ min: 50000, max: 150000 }), + isPublic: faker.datatype.boolean(), + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/factory/user.factory.ts b/libs/typeorm-database/src/lib/seeders/acl/factory/user.factory.ts new file mode 100644 index 00000000..52191398 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/factory/user.factory.ts @@ -0,0 +1,22 @@ +import { faker } from '@faker-js/faker'; +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; +import { UsersAcl } from '../../../entities/acl-test'; + +import { BaseFactory } from '../../base-factory'; + +export class UsersAclFactory extends BaseFactory { + protected entity = UsersAcl; + + protected attrs(): FactorizedAttrs { + const info = { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + } + + return { + login: faker.internet.username(info).toLowerCase(), + isActive: true, + ...info + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/seeds/article.seed.ts b/libs/typeorm-database/src/lib/seeders/acl/seeds/article.seed.ts new file mode 100644 index 00000000..d75b2598 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/seeds/article.seed.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; +import { faker } from '@faker-js/faker'; +import { AclContext } from '../acl.seed'; +import { ArticleFactory } from '../factory'; +import { + ArticleAcl, + ArticleStatus, + ArticleVisibility, +} from '../../../entities'; + +import { BaseSeeder } from '../../base-seeder'; + + +export class ArticleSeed extends BaseSeeder { + async run(dataSource: DataSource, context: AclContext): Promise { + const articleFactory = new ArticleFactory(dataSource); + const alice = context.aclContext.users.find(user => user.login === 'alice')!; + const bob = context.aclContext.users.find(user => user.login === 'bob')!; + const moderator = context.aclContext.users.find(user => user.login === 'moderator')!; + const charlie = context.aclContext.users.find(user => user.login === 'charlie')!; + + const articleData: Partial[] = [{ + title: 'Collaborative Article on AI', + author: alice, + coAuthorIds: [bob.id], + editor: moderator, + }, { + title: 'Premium Content for Members', + author: alice, + metadata: { + readTime: faker.number.int({ min: 5, max: 30 }), + featured: true, + premium: true, // ACL test: premium content + } + }, { + title: 'Article Under Review', + author: bob, + coAuthorIds: [charlie.id], + editor: moderator, + status: ArticleStatus.REVIEW, // ACL test: review status + visibility: ArticleVisibility.UNLISTED, + publishedAt: null, + }, { + title: 'Temporary Access Article', + author: charlie, + expiresAt: new Date('2024-12-31'), // ACL test: time-based access + }, { + title: 'Private Draft Article', + author: bob, + status: ArticleStatus.DRAFT, + visibility: ArticleVisibility.PRIVATE, // ACL test: private visibility + publishedAt: null, + expiresAt: null, + }] + + const count = articleData.length; + context.aclContext.articles = await articleFactory + .each((article) => Object.assign(article, articleData.shift())) + .createMany(count); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/seeds/category.seed.ts b/libs/typeorm-database/src/lib/seeders/acl/seeds/category.seed.ts new file mode 100644 index 00000000..1f46dc3d --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/seeds/category.seed.ts @@ -0,0 +1,65 @@ +import type { EntityData } from '@mikro-orm/core'; +import { DataSource } from 'typeorm'; + +import { AclContext } from '../acl.seed'; +import { CategoryFactory } from '../factory'; +import { CategoryAcl } from '../../../entities'; +import { BaseSeeder } from '../../base-seeder'; + +export class CategorySeed extends BaseSeeder { + async run(dataSource: DataSource, context: AclContext): Promise { + const categoryFactory = new CategoryFactory(dataSource); + + const firstLevelCatData: Partial[] = [ + { + name: 'Technology', + slug: 'technology', + description: 'Technology and programming topics', + }, + { + name: 'Lifestyle', + slug: 'lifestyle', + description: 'Lifestyle and personal development', + }, + { + name: 'Inactive Category', + slug: 'inactive-category', + description: 'This category is inactive', + isActive: false, + }, + ]; + const countFirstLevel = firstLevelCatData.length; + const [techCategory, lifestyleCategory, inactiveCategory] = await categoryFactory + .each((category) => Object.assign(category, firstLevelCatData.shift())) + .createMany(countFirstLevel); + + const secondLevelCatData: EntityData[] = [ + { + name: 'Web Development', + slug: 'web-development', + description: 'Frontend and backend web development', + parent: techCategory, + }, + { + name: 'Artificial Intelligence', + slug: 'artificial-intelligence', + description: 'AI and machine learning', + parent: techCategory, + }, + { + name: 'Health & Fitness', + slug: 'health-fitness', + description: 'Health tips and fitness guides', + parent: lifestyleCategory, + }, + ]; + const countSecondLevel = secondLevelCatData.length; + const [webDevCategory, aiCategory, healthCategory] = await categoryFactory + .each((category) => Object.assign(category, {level: 1}, secondLevelCatData.shift())) + .createMany(countSecondLevel); + + + + context.aclContext.categories = [techCategory, lifestyleCategory, inactiveCategory, webDevCategory, aiCategory, healthCategory] + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/seeds/comment.seed.ts b/libs/typeorm-database/src/lib/seeders/acl/seeds/comment.seed.ts new file mode 100644 index 00000000..c39ab9b7 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/seeds/comment.seed.ts @@ -0,0 +1,54 @@ +import { DataSource } from 'typeorm'; + +import { AclContext } from '../acl.seed'; +import { CommentFactory } from '../factory'; +import { CommentAcl } from '../../../entities'; +import { BaseSeeder } from '../../base-seeder'; + +export class CommentSeed extends BaseSeeder { + async run(dataSource: DataSource, context: AclContext): Promise { + const commentFactory = new CommentFactory(dataSource); + const [post1, post2, post3, post4, post5, post6] = context.aclContext.posts; + const bob = context.aclContext.users.find(user => user.login === 'bob')!; + const charlie = context.aclContext.users.find(user => user.login === 'charlie')!; + const alice = context.aclContext.users.find(user => user.login === 'alice')!; + + const commentData: Partial[] = [{ + post: post1, + author: bob, + content: 'Great tutorial! Very helpful for beginners.', + }, { + post: post1, + author: charlie, + content: 'Thanks for sharing this!', + }, { + post: post1, + author: alice, + content: 'Glad you found it useful!', + }, { + post: post2, + author: bob, + content: 'This needs moderation approval', + isApproved: false, // ACL test: only moderator/admin can see pending + }, { + post: post4, + author: alice, + content: 'Very informative article on health!', + }, { + post: post4, + author: charlie, + content: 'Pending comment for moderation', + isApproved: false, + }, { + post: post6, + author: bob, + content: 'TypeScript is awesome!', + isEdited: true, // Edited comment + }] + + const count = commentData.length; + context.aclContext.comments = await commentFactory + .each((comment) => Object.assign(comment, commentData.shift())) + .createMany(count); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/seeds/document.seed.ts b/libs/typeorm-database/src/lib/seeders/acl/seeds/document.seed.ts new file mode 100644 index 00000000..90337413 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/seeds/document.seed.ts @@ -0,0 +1,38 @@ +import { BaseSeeder } from '../../base-seeder'; +import { DataSource } from 'typeorm'; +import { AclContext } from '../acl.seed'; +import { DocumentFactory } from '../factory'; +import { DocumentAcl } from '../../../entities'; + + +export class DocumentSeed extends BaseSeeder { + async run(dataSource: DataSource, context: AclContext): Promise { + const documentFactory = new DocumentFactory(dataSource); + const alice = context.aclContext.users.find(user => user.login === 'alice')!; + const bob = context.aclContext.users.find(user => user.login === 'bob')!; + const charlie = context.aclContext.users.find(user => user.login === 'charlie')!; + const admin = context.aclContext.users.find(user => user.login === 'admin')!; + const moderator = context.aclContext.users.find(user => user.login === 'moderator')!; + + const documentData: Partial[] = [{ + owner: alice, + sharedWith: [bob.id, charlie.id], // ACL test: shared access + },{ + owner: admin, + isPublic: true, // ACL test: public document (anyone can read) + },{ + owner: bob, + }, { + owner: moderator, + sharedWith: [admin.id, alice.id], + }, { + owner: charlie, + sharedWith: [alice.id] + }] + + const count = documentData.length; + context.aclContext.documents = await documentFactory + .each((document) => Object.assign(document, documentData.shift())) + .createMany(count); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/seeds/index.ts b/libs/typeorm-database/src/lib/seeders/acl/seeds/index.ts new file mode 100644 index 00000000..7bcec93b --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/seeds/index.ts @@ -0,0 +1,7 @@ +export * from './user.seed' +export * from './category.seed' +export * from './post.seed' +export * from './tag.seed' +export * from './comment.seed' +export * from './article.seed' +export * from './document.seed' diff --git a/libs/typeorm-database/src/lib/seeders/acl/seeds/post.seed.ts b/libs/typeorm-database/src/lib/seeders/acl/seeds/post.seed.ts new file mode 100644 index 00000000..ab5e9fab --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/seeds/post.seed.ts @@ -0,0 +1,108 @@ +import { DataSource } from 'typeorm'; +import { AclContext } from '../acl.seed'; +import { PostFactory } from '../factory'; +import { PostAcl, PostStatus, TagAcl } from '../../../entities'; +import { BaseSeeder } from '../../base-seeder'; + + +export class PostSeed extends BaseSeeder { + async run(dataSource: DataSource, context: AclContext): Promise { + const postFactory = new PostFactory(dataSource); + const alice = context.aclContext.users.find( + (user) => user.login === 'alice' + )!; + const bob = context.aclContext.users.find((user) => user.login === 'bob')!; + const charlie = context.aclContext.users.find( + (user) => user.login === 'charlie' + )!; + const webDevCategory = context.aclContext.categories.find( + (category) => category.slug === 'web-development' + )!; + const aiCategory = context.aclContext.categories.find( + (category) => category.slug === 'artificial-intelligence' + )!; + const healthCategory = context.aclContext.categories.find( + (category) => category.slug === 'health-fitness' + )!; + const techCategory = context.aclContext.categories.find( + (category) => category.slug === 'technology' + )!; + + const tagNestJS = context.aclContext.tags.find( + (tag) => tag.slug === 'nestjs' + )!; + const tagTypeScript = context.aclContext.tags.find( + (tag) => tag.slug === 'typescript' + )!; + const tagUserCreated = context.aclContext.tags.find( + (tag) => tag.slug === 'tutorial' + )!; + const tagMachineLearning = context.aclContext.tags.find( + (tag) => tag.slug === 'machine-learning' + )!; + const tagHealthy = context.aclContext.tags.find( + (tag) => tag.slug === 'healthy-living' + )!; + const postData: Partial[] = [ + { + title: 'Getting Started with NestJS', + author: alice, + tags: [tagNestJS, tagTypeScript, tagUserCreated], + category: webDevCategory, + }, + { + title: 'Machine Learning Basics', + author: alice, + tags: [tagMachineLearning], + category: aiCategory, + }, + { + title: 'My Draft Post', + author: alice, + tags: [tagNestJS], + category: webDevCategory, + status: PostStatus.DRAFT, // ACL test: only author/admin can see + isPublished: false, + publishedAt: null, + viewCount: 0, + }, + { + title: 'Healthy Living Tips', + author: bob, + tags: [tagHealthy], + category: healthCategory, + }, + { + title: "Bob's Private Draft", + excerpt: null, + author: bob, + category: healthCategory, + status: PostStatus.DRAFT, + isPublished: false, + publishedAt: null, + viewCount: 0, + }, + { + title: 'TypeScript Best Practices', + author: charlie, + category: webDevCategory, + tags: [tagTypeScript, tagUserCreated], + }, + { + title: 'Archived Post', + author: charlie, + category: techCategory, + status: PostStatus.ARCHIVED, // ACL test: archived posts + isPublished: false, + }, + ]; + const count = postData.length; + context.aclContext.posts = await postFactory + .each((post) => { + const { tags, ...other } = postData.shift()!; + Object.assign(post, other); + post.tags = tags as TagAcl[]; + }) + .createMany(count); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/seeds/tag.seed.ts b/libs/typeorm-database/src/lib/seeders/acl/seeds/tag.seed.ts new file mode 100644 index 00000000..5045643d --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/seeds/tag.seed.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; +import { AclContext } from '../acl.seed'; +import { TagFactory } from '../factory'; +import { TagAcl } from '../../../entities'; +import { BaseSeeder } from '../../base-seeder'; + +export class TagSeed extends BaseSeeder { + async run(dataSource: DataSource, context: AclContext): Promise { + const tagFactory = new TagFactory(dataSource); + const admin = context.aclContext.users.find( + (user) => user.login === 'admin' + )!; + const moderator = context.aclContext.users.find( + (user) => user.login === 'moderator' + )!; + const alice = context.aclContext.users.find( + (user) => user.login === 'alice' + )!; + const tagData: Partial[] = [ + { + name: 'NestJS', + slug: 'nestjs', + description: 'NestJS framework', + createdBy: admin, + isOfficial: true, + }, + { + name: 'TypeScript', + slug: 'typescript', + description: 'TypeScript language', + createdBy: admin, + isOfficial: true, + }, + { + name: 'Machine Learning', + slug: 'machine-learning', + description: 'ML and AI', + createdBy: admin, + isOfficial: true, + }, + { + name: 'Healthy Living', + slug: 'healthy-living', + description: 'Health and wellness', + createdBy: moderator, + }, + { + name: 'Tutorial', + slug: 'tutorial', + description: 'Tutorial posts', + createdBy: alice, + isOfficial: false, // ACL test: user-created tags + }, + ]; + + const count = tagData.length; + context.aclContext.tags = await tagFactory + .each((tag) => Object.assign(tag, tagData.shift())) + .createMany(count); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/acl/seeds/user.seed.ts b/libs/typeorm-database/src/lib/seeders/acl/seeds/user.seed.ts new file mode 100644 index 00000000..49059c3a --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/acl/seeds/user.seed.ts @@ -0,0 +1,51 @@ +import { UserProfileFactory, UsersAclFactory } from '../factory'; +import { UserRole } from '../../../entities'; +import { AclContext } from '../acl.seed'; +import { BaseSeeder } from '../../base-seeder'; +import { DataSource } from 'typeorm'; +import { SingleSubfactory } from '@jorgebodega/typeorm-factory'; + + +export class UsersSeed extends BaseSeeder { + async run(dataSource: DataSource, context: AclContext) { + const userFactory = new UsersAclFactory(dataSource); + const profileFactory = new UserProfileFactory(dataSource); + + const usersArray: string[] = [ + 'inactive', + 'alice', + 'bob', + 'charlie', + 'admin', + 'moderator', + ]; + + const profileData: { + role: UserRole; + isPublic?: boolean; + }[] = [ + ...new Array(4).fill(null).map((_, i, array) => ({ + role: UserRole.user, + isPublic: i !== array.length - 1, + })), + { role: UserRole.admin }, + { role: UserRole.moderator }, + ]; + const count = profileData.length; + let activeUser = false; + context.aclContext.users = await userFactory + .each((user) => { + user.login = usersArray.shift() as string; + user.profile = new SingleSubfactory(profileFactory, { + ...profileData.shift(), + ...(user.login === 'bob' ? { isPublic: false } : {}), + firstName: user.firstName as string, + lastName: user.lastName as string, + }); + + user.isActive = activeUser; + activeUser = !activeUser; + }) + .createMany(count); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/api/api.seed.ts b/libs/typeorm-database/src/lib/seeders/api/api.seed.ts new file mode 100644 index 00000000..8fa2933c --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/api.seed.ts @@ -0,0 +1,28 @@ +import { DataSource } from 'typeorm'; +import { Context } from '../database.seeder'; +import { Roles } from '../../entities'; +import { BaseSeeder } from '../base-seeder'; + +import { RolesSeed, UsersSeed } from './seeds'; + +export type InnerApiContext = { + roles: Roles[]; +}; + +export type ApiContext = Context & { apiContext: InnerApiContext }; + +export class ApiSeeder extends BaseSeeder{ + + async run(dataSource: DataSource, context: Context = {}): Promise { + const innerContext: InnerApiContext = { + roles: [], + }; + const apiContext = { + ...context, + apiContext: innerContext, + }; + + await this.call(dataSource, [RolesSeed], apiContext); + await this.call(dataSource, [UsersSeed], apiContext); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/api/factory/addresses.factory.ts b/libs/typeorm-database/src/lib/seeders/api/factory/addresses.factory.ts new file mode 100644 index 00000000..48454508 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/factory/addresses.factory.ts @@ -0,0 +1,16 @@ +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; +import { faker } from '@faker-js/faker'; + +import { Addresses } from '../../../entities'; +import { BaseFactory } from '../../base-factory'; + +export class AddressesFactory extends BaseFactory { + protected entity = Addresses; + protected attrs(): FactorizedAttrs { + return { + city: faker.location.city(), + state: faker.location.state(), + country: faker.location.country(), + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/api/factory/book-list.factory.ts b/libs/typeorm-database/src/lib/seeders/api/factory/book-list.factory.ts new file mode 100644 index 00000000..1e8649ae --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/factory/book-list.factory.ts @@ -0,0 +1,13 @@ +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; +import { faker } from '@faker-js/faker'; +import { BookList } from '../../../entities'; +import { BaseFactory } from '../../base-factory'; + +export class BookListFactory extends BaseFactory { + protected entity = BookList; + protected attrs(): FactorizedAttrs { + return { + text: faker.book.title(), + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/api/factory/comments.factory.ts b/libs/typeorm-database/src/lib/seeders/api/factory/comments.factory.ts new file mode 100644 index 00000000..0527b1e1 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/factory/comments.factory.ts @@ -0,0 +1,15 @@ +import { FactorizedAttrs} from '@jorgebodega/typeorm-factory'; +import { faker } from '@faker-js/faker'; +import { CommentKind, Comments } from '../../../entities'; +import { BaseFactory } from '../../base-factory'; + +export class CommentsFactory extends BaseFactory { + protected entity = Comments; + protected attrs(): FactorizedAttrs { + const text = faker.lorem.paragraph(faker.number.int(5)); + return { + kind: CommentKind.Comment, + text, + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/api/factory/index.ts b/libs/typeorm-database/src/lib/seeders/api/factory/index.ts new file mode 100644 index 00000000..a8d418ff --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/factory/index.ts @@ -0,0 +1,5 @@ +export * from './user.factory' +export * from './addresses.factory' +export * from './book-list.factory' +export * from './roles.factory' +export * from './comments.factory'; diff --git a/libs/typeorm-database/src/lib/seeders/api/factory/roles.factory.ts b/libs/typeorm-database/src/lib/seeders/api/factory/roles.factory.ts new file mode 100644 index 00000000..3801db83 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/factory/roles.factory.ts @@ -0,0 +1,18 @@ +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; +import { Roles } from '../../../entities'; +import { BaseFactory } from '../../base-factory'; + +export class RolesFactory extends BaseFactory { + protected entity = Roles; + protected rolesList = ['USERS', 'ADMIN', 'OTHER']; + protected attrs(): FactorizedAttrs { + const role = this.rolesList.shift(); + if (!role) { + throw new Error('Role is empty'); + } + return { + name: role.toLowerCase(), + key: role, + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/api/factory/user.factory.ts b/libs/typeorm-database/src/lib/seeders/api/factory/user.factory.ts new file mode 100644 index 00000000..2d457b7f --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/factory/user.factory.ts @@ -0,0 +1,26 @@ +import { faker, Sex } from '@faker-js/faker'; +import { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; +import { Users } from '../../../entities'; +import { BaseFactory } from '../../base-factory'; + + +export class UsersFactory extends BaseFactory { + protected entity = Users; + protected genderList: Record = { + [0]: Sex.Male, + [1]: Sex.Female, + }; + + protected attrs(): FactorizedAttrs { + const gender: Sex = this.genderList[faker.number.int(1)]; + + const firstName = faker.person.firstName(gender); + const lastName = faker.person.lastName(gender); + return { + login: faker.internet.username({ firstName, lastName }), + firstName: firstName, + lastName: lastName, + isActive: faker.datatype.boolean(), + }; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/api/seeds/index.ts b/libs/typeorm-database/src/lib/seeders/api/seeds/index.ts new file mode 100644 index 00000000..d9a70d5d --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/seeds/index.ts @@ -0,0 +1,2 @@ +export * from './user.seed'; +export * from './roles.seed'; diff --git a/libs/typeorm-database/src/lib/seeders/api/seeds/roles.seed.ts b/libs/typeorm-database/src/lib/seeders/api/seeds/roles.seed.ts new file mode 100644 index 00000000..caf1ee76 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/seeds/roles.seed.ts @@ -0,0 +1,12 @@ +import { DataSource } from 'typeorm'; + +import { ApiContext } from '../api.seed'; +import { RolesFactory } from '../factory'; +import { BaseSeeder } from '../../base-seeder'; + + +export class RolesSeed extends BaseSeeder { + async run(dataSource: DataSource, context: ApiContext) { + context.apiContext.roles = await new RolesFactory(dataSource).createMany(3); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/api/seeds/user.seed.ts b/libs/typeorm-database/src/lib/seeders/api/seeds/user.seed.ts new file mode 100644 index 00000000..31eb046e --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/api/seeds/user.seed.ts @@ -0,0 +1,67 @@ +import { faker } from '@faker-js/faker'; +import { + CollectionSubfactory, + SingleSubfactory, +} from '@jorgebodega/typeorm-factory'; +import { DataSource } from 'typeorm'; + +import { ApiContext } from '../api.seed'; + +import { + UsersFactory, + AddressesFactory, + BookListFactory, + CommentsFactory, +} from '../factory'; + +import { BaseSeeder } from '../../base-seeder'; + + +export class UsersSeed extends BaseSeeder { + async run(dataSource: DataSource, context: ApiContext) { + const { roles } = context.apiContext; + + const manager = await new UsersFactory(dataSource) + .each((user) => { + user.addresses = new SingleSubfactory(new AddressesFactory(dataSource)); + user.roles = () => + faker.helpers.arrayElements(roles, { min: 1, max: roles.length }); + user.login = 'manager:' + user.login; + user.books = new CollectionSubfactory( + new BookListFactory(dataSource), + faker.number.int(20) + ); + }) + .createMany(3); + + await new UsersFactory(dataSource) + .each((user) => { + user.addresses = new SingleSubfactory(new AddressesFactory(dataSource)); + user.roles = () => + faker.helpers.arrayElements(roles, { min: 1, max: roles.length }); + user.books = new CollectionSubfactory( + new BookListFactory(dataSource), + faker.number.int(20) + ); + user.manager = manager.shift()!; + }) + .createMany(manager.length); + + await new UsersFactory(dataSource) + .each((user) => { + user.addresses = new SingleSubfactory(new AddressesFactory(dataSource)); + user.roles = () => + faker.helpers.arrayElements(roles, { min: 1, max: roles.length }); + user.login = 'without-manager:' + user.login; + user.books = new CollectionSubfactory( + new BookListFactory(dataSource), + faker.number.int(20) + ); + user.comments = new CollectionSubfactory( + new CommentsFactory(dataSource), + faker.number.int(100) + ); + }) + .createMany(manager.length); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/base-factory.ts b/libs/typeorm-database/src/lib/seeders/base-factory.ts new file mode 100644 index 00000000..3f000655 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/base-factory.ts @@ -0,0 +1,55 @@ +import { Factory, InstanceAttribute } from '@jorgebodega/typeorm-factory'; +import { DataSource, type SaveOptions } from 'typeorm'; +import type { FactorizedAttrs } from '@jorgebodega/typeorm-factory'; + +export abstract class BaseFactory extends Factory { + constructor(protected dataSource: DataSource) { + super(); + } + protected abstract override attrs(): FactorizedAttrs; + private eachFunction?: (entity: Partial>) => void; + + override async make(overrideParams: Partial> = {}) { + const attrs = { ...this.attrs(), ...overrideParams }; + if (this.eachFunction) { + this.eachFunction(attrs); + } + // @ts-ignore + const entity = await this.makeEntity(attrs, false); + // @ts-ignore + await this.applyEagerInstanceAttributes(entity, attrs, false); + // @ts-ignore + await this.applyLazyInstanceAttributes(entity, attrs, false); + return entity; + } + + override async create( + overrideParams: Partial> = {}, + saveOptions?: SaveOptions + ) { + const attrs = { ...this.attrs(), ...overrideParams }; + if (this.eachFunction) { + this.eachFunction(attrs); + } + const preloadedAttrs = Object.entries(attrs).filter( + ([, value]) => !(value instanceof InstanceAttribute) + ); + // @ts-ignore + const entity = await this.makeEntity( + Object.fromEntries(preloadedAttrs), + true + ); + // @ts-ignore + await this.applyEagerInstanceAttributes(entity, attrs, true); + const em = this.dataSource.createEntityManager(); + const savedEntity = await em.save(entity, saveOptions); + // @ts-ignore + await this.applyLazyInstanceAttributes(savedEntity, attrs, true); + return em.save(savedEntity, saveOptions); + } + + each(eachFunction: (entity: Partial>) => void) { + this.eachFunction = eachFunction; + return this; + } +} diff --git a/libs/typeorm-database/src/lib/seeders/base-seeder.ts b/libs/typeorm-database/src/lib/seeders/base-seeder.ts new file mode 100644 index 00000000..b73a3a76 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/base-seeder.ts @@ -0,0 +1,17 @@ +import { DataSource } from 'typeorm'; + + +export type Dictionary = { + [k: string]: T; +}; + +export abstract class BaseSeeder { + abstract run(dataSource: DataSource, context:T): Promise + protected async call(dataSource: DataSource, seeders: { + new (): BaseSeeder; + }[], context?: T): Promise{ + for (const seeder of seeders) { + await new seeder().run(dataSource, context || {}) + } + } +} diff --git a/libs/typeorm-database/src/lib/seeders/database.seeder.ts b/libs/typeorm-database/src/lib/seeders/database.seeder.ts new file mode 100644 index 00000000..ae8e4615 --- /dev/null +++ b/libs/typeorm-database/src/lib/seeders/database.seeder.ts @@ -0,0 +1,14 @@ +import { Seeder } from '@jorgebodega/typeorm-seeding'; +import { DataSource } from 'typeorm'; + +import { ApiSeeder } from './api/api.seed'; +import { AclSeed } from './acl/acl.seed'; +export type Context = {}; + +export default class DatabaseSeeder extends Seeder { + async run(dataSource: DataSource): Promise { + const context: Context = {}; + await new ApiSeeder().run(dataSource, context) + await new AclSeed().run(dataSource, context); + } +} diff --git a/libs/typeorm-database/src/lib/seeders/factory/addresses.factory.ts b/libs/typeorm-database/src/lib/seeders/factory/addresses.factory.ts deleted file mode 100644 index ff456566..00000000 --- a/libs/typeorm-database/src/lib/seeders/factory/addresses.factory.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FactorizedAttrs, Factory } from '@jorgebodega/typeorm-factory'; -import { faker } from '@faker-js/faker'; - -import { Addresses } from '../../entities'; -import { DataSource } from 'typeorm'; - -export class AddressesFactory extends Factory { - protected entity = Addresses; - protected attrs(): FactorizedAttrs { - return { - city: faker.location.city(), - state: faker.location.state(), - country: faker.location.country(), - }; - } - - constructor(protected dataSource: DataSource) { - super(); - } -} diff --git a/libs/typeorm-database/src/lib/seeders/factory/comments.factory.ts b/libs/typeorm-database/src/lib/seeders/factory/comments.factory.ts deleted file mode 100644 index 2df847c6..00000000 --- a/libs/typeorm-database/src/lib/seeders/factory/comments.factory.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { FactorizedAttrs, Factory } from '@jorgebodega/typeorm-factory'; -import { faker } from '@faker-js/faker'; -import { DataSource } from 'typeorm'; -import { CommentKind, Comments } from '../../entities'; - -export class CommentsFactory extends Factory { - protected entity = Comments; - protected attrs(): FactorizedAttrs { - const text = faker.lorem.paragraph(faker.number.int(5)); - return { - kind: CommentKind.Comment, - text, - }; - } - - constructor(protected dataSource: DataSource) { - super(); - } -} diff --git a/libs/typeorm-database/src/lib/seeders/factory/index.ts b/libs/typeorm-database/src/lib/seeders/factory/index.ts deleted file mode 100644 index 27773a2d..00000000 --- a/libs/typeorm-database/src/lib/seeders/factory/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './addresses.factory'; -export * from './comments.factory'; -export * from './user.factory'; -export * from './roles.factory'; diff --git a/libs/typeorm-database/src/lib/seeders/factory/roles.factory.ts b/libs/typeorm-database/src/lib/seeders/factory/roles.factory.ts deleted file mode 100644 index 71edace7..00000000 --- a/libs/typeorm-database/src/lib/seeders/factory/roles.factory.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FactorizedAttrs, Factory } from '@jorgebodega/typeorm-factory'; -import { DataSource } from 'typeorm'; -import { faker } from '@faker-js/faker'; -import { Roles } from '../../entities'; - -export class RolesFactory extends Factory { - protected entity = Roles; - protected rolesList = ['USERS', 'ADMIN', 'OTHER']; - protected attrs(): FactorizedAttrs { - const key = faker.number.int(this.rolesList.length - 1); - return { - name: this.rolesList[key].toLowerCase(), - key: this.rolesList[key], - }; - } - - constructor(protected dataSource: DataSource) { - super(); - } -} diff --git a/libs/typeorm-database/src/lib/seeders/factory/user.factory.ts b/libs/typeorm-database/src/lib/seeders/factory/user.factory.ts deleted file mode 100644 index 8a66c7a8..00000000 --- a/libs/typeorm-database/src/lib/seeders/factory/user.factory.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - FactorizedAttrs, - Factory, - CollectionSubfactory, - SingleSubfactory, -} from '@jorgebodega/typeorm-factory'; -import { DataSource } from 'typeorm'; -import { faker, Sex, Faker } from '@faker-js/faker'; - -import { Users } from '../../entities'; -import { AddressesFactory, CommentsFactory } from '.'; - -export class UserFactory extends Factory { - protected genderList: Record = { - [0]: Sex.Male, - [1]: Sex.Female, - }; - - protected entity = Users; - protected attrs(): FactorizedAttrs { - const gender: Sex = this.genderList[faker.number.int(1)]; - - const firstName = faker.person.firstName(gender); - const lastName = faker.person.lastName(gender); - return { - login: faker.internet.userName({ firstName, lastName }), - isActive: faker.datatype.boolean(), - firstName: firstName, - lastName: lastName, - comments: new CollectionSubfactory( - new CommentsFactory(this.dataSource), - faker.number.int(100) - ), - addresses: new SingleSubfactory(new AddressesFactory(this.dataSource)), - }; - } - - constructor(protected dataSource: DataSource) { - super(); - } -} diff --git a/libs/typeorm-database/src/lib/seeders/root.seeder.ts b/libs/typeorm-database/src/lib/seeders/root.seeder.ts deleted file mode 100644 index 7e177691..00000000 --- a/libs/typeorm-database/src/lib/seeders/root.seeder.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Seeder } from '@jorgebodega/typeorm-seeding'; -import { DataSource } from 'typeorm'; - -import { AddressesFactory, UserFactory } from './factory'; - -export default class RootSeeder extends Seeder { - async run(dataSource: DataSource): Promise { - await new UserFactory(dataSource).createMany(10); - await new AddressesFactory(dataSource).createMany(10); - } -} diff --git a/libs/typeorm-database/src/lib/type-orm-database.module.ts b/libs/typeorm-database/src/lib/type-orm-database.module.ts index 39a65f32..fbcbab43 100644 --- a/libs/typeorm-database/src/lib/type-orm-database.module.ts +++ b/libs/typeorm-database/src/lib/type-orm-database.module.ts @@ -1,10 +1,37 @@ -import { Module } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; - +import { TypeOrmLoggerService } from './type-orm-logger.service'; import { config } from './config'; +import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; + +@Module({}) +export class TypeOrmDatabaseModule { + static forRoot(): DynamicModule { + const typeOrmModule = TypeOrmModule.forRootAsync({ + name: 'default', + useFactory: (typeOrmLoggerService: TypeOrmLoggerService) => ({ + ...config, + logger: typeOrmLoggerService, + }), + inject: [TypeOrmLoggerService], + extraProviders: [TypeOrmLoggerService], + }); -@Module({ - imports: [TypeOrmModule.forRoot(config)], - exports: [TypeOrmModule], -}) -export class TypeOrmDatabaseModule {} + return { + module: TypeOrmDatabaseModule, + imports: [typeOrmModule], + exports: [typeOrmModule], + }; + } + static forFeature(entities: EntityClassOrSchema[] = []): DynamicModule { + const { providers, exports } = TypeOrmModule.forFeature( + entities, + 'default' + ); + return { + module: TypeOrmDatabaseModule, + providers, + exports, + }; + } +} diff --git a/libs/typeorm-database/src/lib/type-orm-logger.service.ts b/libs/typeorm-database/src/lib/type-orm-logger.service.ts new file mode 100755 index 00000000..4ebaae6b --- /dev/null +++ b/libs/typeorm-database/src/lib/type-orm-logger.service.ts @@ -0,0 +1,45 @@ +import { AbstractLogger, LogMessage, QueryRunner, LogLevel } from 'typeorm'; +import { Injectable, Logger as NestLogger } from '@nestjs/common'; +import { config } from './config'; +import { LoggerOptions } from 'typeorm/logger/LoggerOptions'; + +@Injectable() +export class TypeOrmLoggerService extends AbstractLogger { + private logger: NestLogger = new NestLogger(TypeOrmLoggerService.name); + protected override options: LoggerOptions | undefined = config.logging ? 'all' : undefined; + + protected writeLog( + level: LogLevel, + logMessage: LogMessage | string | number | (LogMessage | string | number)[], + ): void { + + const messages = this.prepareLogMessages(logMessage, { + highlightSql: true, + }); + for (const message of messages) { + switch (message.type ?? level) { + case 'log': + case 'schema-build': + case 'migration': + this.logger.log(message.message); + break; + case 'info': + case 'query': + this.logger.debug(this.getStringMsg(message.message, message.prefix)); + break; + case 'warn': + case 'query-slow': + this.logger.warn(this.getStringMsg(message.message, message.prefix)); + break; + case 'error': + case 'query-error': + this.logger.error(this.getStringMsg(message.message, message.prefix)); + break; + } + } + } + + private getStringMsg(msg: string | number, prefix?: string): string { + return prefix ? `${prefix} ${msg}` : `${msg}`; + } +} diff --git a/nx.json b/nx.json index 58c159ea..459db87d 100644 --- a/nx.json +++ b/nx.json @@ -155,5 +155,8 @@ "releaseTagPattern": "{projectName}@{version}" }, "defaultBase": "master", - "nxCloudId": "67c859dd862a93f16da79c89" + "nxCloudId": "67c859dd862a93f16da79c89", + "tui": { + "enabled": false + } } diff --git a/package-lock.json b/package-lock.json index e5a3e696..626388a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,14 @@ "@angular/platform-browser": "20.3.4", "@angular/platform-browser-dynamic": "20.3.4", "@angular/router": "20.3.4", + "@casl/ability": "^6.7.3", "@mikro-orm/cli": "^6.4.0", "@mikro-orm/core": "^6.4.0", "@mikro-orm/migrations": "^6.4.0", "@mikro-orm/mysql": "^6.4.0", "@mikro-orm/nestjs": "^6.1.0", "@mikro-orm/postgresql": "^6.4.0", + "@mikro-orm/seeder": "^6.4.0", "@mikro-orm/sql-highlighter": "^1.0.0", "@nestjs/common": "^11.1.0", "@nestjs/core": "^11.1.0", @@ -37,9 +39,12 @@ "change-case-commonjs": "^5.4.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "mikro-orm-pglite": "^0.5.1", + "nestjs-cls": "^6.0.1", "nestjs-pino": "4.3.0", "pg": "8.11.3", "pino-http": "10.4.0", + "pino-pretty": "^13.1.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", "socket.io-client": "^4.7.5", @@ -61,8 +66,8 @@ "@angular/cli": "~20.3.0", "@angular/compiler-cli": "20.3.4", "@angular/language-service": "20.3.4", - "@electric-sql/pglite": "^0.2.16", - "@faker-js/faker": "^8.4.1", + "@electric-sql/pglite": "^0.3.0", + "@faker-js/faker": "^10.0.0", "@jorgebodega/typeorm-factory": "^1.4.0", "@jorgebodega/typeorm-seeding": "^6.0.1", "@nestjs/schematics": "11.0.9", @@ -79,6 +84,9 @@ "@nx/webpack": "21.6.2", "@nx/workspace": "21.6.2", "@schematics/angular": "20.3.5", + "@suites/di.nestjs": "^3.0.1", + "@suites/doubles.vitest": "^3.0.1", + "@suites/unit": "^3.0.1", "@swc-node/register": "1.10.9", "@swc/cli": "~0.6.0", "@swc/core": "1.11.7", @@ -97,7 +105,6 @@ "jiti": "2.4.2", "jsdom": "~22.1.0", "jsonc-eslint-parser": "^2.1.0", - "knex-pglite": "^0.11.0", "ng-packagr": "20.3.0", "nx": "21.6.2", "pg-mem": "^3.0.2", @@ -3875,6 +3882,18 @@ "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@casl/ability": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.3.tgz", + "integrity": "sha512-A4L28Ko+phJAsTDhRjzCOZWECQWN2jzZnJPnROWWHjJpyMq1h7h9ZqjwS2WbIUa3Z474X1ZPSgW0f1PboZGC0A==", + "license": "MIT", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "devOptional": true, @@ -3969,8 +3988,9 @@ } }, "node_modules/@electric-sql/pglite": { - "version": "0.2.16", - "dev": true, + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.11.tgz", + "integrity": "sha512-FJtjnEyez8XgmgyE5Ewmx89TGVN+75ZjykFoExApRIbJBMT4dsbsuZkF/YWLuymGDfGFHDACjvENPMEqg4FoWg==", "license": "Apache-2.0" }, "node_modules/@emnapi/core": { @@ -4561,7 +4581,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "8.4.1", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", + "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", "dev": true, "funding": [ { @@ -4571,8 +4593,21 @@ ], "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/@harryplusplus/knex-pglite": { + "version": "0.13.13", + "resolved": "https://registry.npmjs.org/@harryplusplus/knex-pglite/-/knex-pglite-0.13.13.tgz", + "integrity": "sha512-+c1Bs2aXgkc2NY3/EvhfP5Yv/xXDPS9TBPvR9EVU2J2LkwgrZbOFFmoYRBl5o5q2iSzmaGbLi5xdbicHSceFTA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.5" + }, + "peerDependencies": { + "@electric-sql/pglite": "^0.3.10", + "knex": "^3.1.0" } }, "node_modules/@humanwhocodes/config-array": { @@ -6321,11 +6356,15 @@ } }, "node_modules/@mikro-orm/postgresql/node_modules/pg-connection-string": { - "version": "2.7.0", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", "license": "MIT" }, "node_modules/@mikro-orm/postgresql/node_modules/postgres-array": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", "license": "MIT", "engines": { "node": ">=12" @@ -6345,6 +6384,36 @@ "node": ">=12" } }, + "node_modules/@mikro-orm/seeder": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/@mikro-orm/seeder/-/seeder-6.4.3.tgz", + "integrity": "sha512-wxByIr29zXVBiWNkXj99dEu6dSb3Vv72LBiqkExUToeBWCLgZr74b/QPY5lpiVdIcfyNdlijUdCjX40cDcJuDQ==", + "license": "MIT", + "dependencies": { + "fs-extra": "11.2.0", + "globby": "11.1.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0" + } + }, + "node_modules/@mikro-orm/seeder/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@mikro-orm/sql-highlighter": { "version": "1.0.1", "license": "MIT", @@ -13077,6 +13146,116 @@ "version": "1.2.5", "license": "MIT" }, + "node_modules/@suites/core.unit": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@suites/core.unit/-/core.unit-3.0.1.tgz", + "integrity": "sha512-X5xNg5EK1zAKXo4WS1fyBcViyvWvUWCEaA1fv/3ow5mD9AkKnIQajqm9pLAkUl/BM73baLQv9biLnmEHje1ghA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@suites/types.common": "^3.0.0", + "@suites/types.di": "^3.0.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + } + }, + "node_modules/@suites/di.nestjs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@suites/di.nestjs/-/di.nestjs-3.0.1.tgz", + "integrity": "sha512-mcXq9GgaE1hC/5Qu69tJZrhSE7dcWJAWHzRvnRyx6UV9XP2Ze6aRK4/uCkviG84A1il4QLNQy+NvhOf0LooDgA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@suites/types.common": "^3.0.0", + "@suites/types.di": "^3.0.0" + }, + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + }, + "peerDependencies": { + "@nestjs/common": ">= 8.0", + "reflect-metadata": "<1.0.0" + } + }, + "node_modules/@suites/doubles.vitest": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@suites/doubles.vitest/-/doubles.vitest-3.0.1.tgz", + "integrity": "sha512-CldGybIRIUNR88CXWb9ofCAoIg7RnBQ3uItpe7b0YGFhSl5fFwuzQuqBjUVkof0Fe9PyloCZ0U1lAzcxwgfwww==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@suites/core.unit": "^3.0.1", + "@suites/types.common": "^3.0.0", + "@suites/types.doubles": "^3.0.0" + }, + "engines": { + "node": "^18.12.0 || >=20.0.0" + }, + "peerDependencies": { + "@vitest/spy": ">= 1.0", + "vitest": ">= 1.0" + } + }, + "node_modules/@suites/types.common": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@suites/types.common/-/types.common-3.0.0.tgz", + "integrity": "sha512-+kCWmVAyEI01P4d/t3LbrOCq1xXQlh3SsPstn1uBuC/MDMW/pENHkU5xVC9G8VOW/pkXh53KXwbHIxpJImp4Zw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + } + }, + "node_modules/@suites/types.di": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@suites/types.di/-/types.di-3.0.0.tgz", + "integrity": "sha512-RzvgfTsjg3KrJ3SRbo9J84g5mRfkMZMzinLTjAQO+yCBBz+YMhbwaUXDBX70RcFGiAHjTNPg0jWjis3FHPH8Dg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@suites/types.common": "^3.0.0" + }, + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + } + }, + "node_modules/@suites/types.doubles": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@suites/types.doubles/-/types.doubles-3.0.0.tgz", + "integrity": "sha512-B5sHH98qU7Fq+ozA42J3LLv846xRJn2ndx1xfLr0oaKxEG6Xud/vmdJpH65F/2P7DNMJkxWo/VDIqXrN4UDcvg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@suites/unit": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@suites/unit/-/unit-3.0.1.tgz", + "integrity": "sha512-28RLtgxG8NH6zcBvfiMGqbToWNTs2WnTPa9GWQ2k1laXueGILDTTRjQrsApIAlbtRF5lGuGPynRFEMBAL+90Mw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/suites-dev" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/suites-dev" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@suites/core.unit": "^3.0.1", + "@suites/types.common": "^3.0.0", + "@suites/types.di": "^3.0.0", + "@suites/types.doubles": "^3.0.0" + }, + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + } + }, "node_modules/@swc-node/core": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/@swc-node/core/-/core-1.13.3.tgz", @@ -14138,6 +14317,41 @@ "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==", + "license": "Apache-2.0" + }, + "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==", + "license": "Apache-2.0", + "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==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.4.0.tgz", + "integrity": "sha512-vR9RJ3BHlkI3RfKJIZFdVktxWvBCQRiSTeJSWN9NPxP5YJkpfXvcBWAMLwvyJx4HbB+qib5/AlSDEmQiuQyx2w==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -17760,7 +17974,9 @@ } }, "node_modules/chokidar": { - "version": "4.0.1", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { @@ -18092,7 +18308,6 @@ }, "node_modules/colorette": { "version": "2.0.20", - "devOptional": true, "license": "MIT" }, "node_modules/colorjs.io": { @@ -18251,7 +18466,9 @@ } }, "node_modules/consola": { - "version": "3.4.0", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -18782,6 +18999,15 @@ "node": ">=4.0" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -20318,6 +20544,12 @@ "node >=0.6.0" ] }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "license": "MIT" @@ -21642,6 +21874,12 @@ "he": "bin/he" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/highlight.js": { "version": "10.7.3", "license": "BSD-3-Clause", @@ -22849,6 +23087,15 @@ "version": "1.4.0", "license": "MIT" }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "dev": true, @@ -23246,15 +23493,6 @@ } } }, - "node_modules/knex-pglite": { - "version": "0.11.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@electric-sql/pglite": "^0.2.14", - "knex": "3.1.0" - } - }, "node_modules/knex/node_modules/colorette": { "version": "2.0.19", "license": "MIT" @@ -23788,6 +24026,14 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "dev": true }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -24444,6 +24690,22 @@ "node": ">= 18.12.0" } }, + "node_modules/mikro-orm-pglite": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mikro-orm-pglite/-/mikro-orm-pglite-0.5.1.tgz", + "integrity": "sha512-/0iSHafJliKlyNXDxN69PkU6OEcCne3at6rWRJ/g0TK9N+gd5UY2CGjmzZ2uIV2hB8+gFVE3Gp1s7Fj0jfP9aA==", + "license": "MIT", + "dependencies": { + "@harryplusplus/knex-pglite": "0.13.13" + }, + "engines": { + "node": ">= 20.19.5" + }, + "peerDependencies": { + "@electric-sql/pglite": "^0.3.10", + "@mikro-orm/postgresql": "^6.5.6" + } + }, "node_modules/mime": { "version": "1.6.0", "dev": true, @@ -25035,6 +25297,21 @@ "dev": true, "license": "MIT" }, + "node_modules/nestjs-cls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-6.0.1.tgz", + "integrity": "sha512-FnU8MI5/RKdbNvGmlUMD7nuFs7zUiueNzTjO+sxj7BJbsf7cUmosarppPLXRJ7C1WaZ/gLE9ZAcM6//0q1CQIA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@nestjs/common": ">= 10 < 12", + "@nestjs/core": ">= 10 < 12", + "reflect-metadata": "*", + "rxjs": ">= 7" + } + }, "node_modules/nestjs-pino": { "version": "4.3.0", "hasInstallScript": true, @@ -26783,7 +27060,9 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.1.1", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "license": "MIT", "optional": true }, @@ -26872,14 +27151,18 @@ "license": "ISC" }, "node_modules/pg-pool": { - "version": "3.7.0", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.7.0", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", "license": "MIT" }, "node_modules/pg-types": { @@ -26974,6 +27257,52 @@ "process-warning": "^4.0.0" } }, + "node_modules/pino-pretty": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.2.tgz", + "integrity": "sha512-3cN0tCakkT4f3zo9RXDIhy6GTvtYD6bK4CRBLN9j3E/ePqN1tugAXD5rGVfoChW6s0hiek+eyYlLNqc/BG7vBQ==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pino-std-serializers": { "version": "7.0.0", "license": "MIT" @@ -29262,6 +29591,22 @@ "dev": true, "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/seek-bzip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz", diff --git a/package.json b/package.json index 4c06c7ce..2309978d 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,15 @@ ], "scripts": { "typeorm": "ts-node -r tsconfig-paths/register -r dotenv/config --project libs/typeorm-database/tsconfig.lib.json ./node_modules/typeorm/cli.js -d libs/typeorm-database/src/lib/config-cli.ts", - "typeorm:run": "npm run typeorm migration:run", + "typeorm:up:remove": "rm -rf ./tmp/pg/typeorm && npm run typeorm migration:run", + "typeorm:up": "npm run typeorm migration:run", "typeorm:revert": "npm run typeorm migration:revert", "typeorm:generate": "npm run typeorm migration:generate", - "seed:run": "ts-node -r tsconfig-paths/register -r dotenv/config --project libs/typeorm-database/tsconfig.lib.json ./node_modules/@jorgebodega/typeorm-seeding/dist/cli.js -d libs/typeorm-database/src/lib/config-cli.ts seed libs/typeorm-database/src/lib/seeders/root.seeder.ts", + "typeorm:seeder": "ts-node -r tsconfig-paths/register -r dotenv/config --project libs/typeorm-database/tsconfig.lib.json ./node_modules/@jorgebodega/typeorm-seeding/dist/cli.js -d libs/typeorm-database/src/lib/config-cli.ts seed libs/typeorm-database/src/lib/seeders/database.seeder.ts", "microorm": "ts-node -r tsconfig-paths/register -r dotenv/config -P ./libs/microorm-database/tsconfig.lib.json ./node_modules/@mikro-orm/cli/cli.js", + "microorm:up:remove": "rm -rf ./tmp/pg/microorm && npm run microorm migration:up", "microorm:up": "npm run microorm migration:up", + "microorm:seeder": "npm run microorm seeder:run", "demo:json-api": "nx run json-api-server:serve:development", "demo:json-api-front": "nx run json-api-front:serve:development" }, @@ -39,12 +42,14 @@ "@angular/platform-browser": "20.3.4", "@angular/platform-browser-dynamic": "20.3.4", "@angular/router": "20.3.4", + "@casl/ability": "^6.7.3", "@mikro-orm/cli": "^6.4.0", "@mikro-orm/core": "^6.4.0", "@mikro-orm/migrations": "^6.4.0", "@mikro-orm/mysql": "^6.4.0", "@mikro-orm/nestjs": "^6.1.0", "@mikro-orm/postgresql": "^6.4.0", + "@mikro-orm/seeder": "^6.4.0", "@mikro-orm/sql-highlighter": "^1.0.0", "@nestjs/common": "^11.1.0", "@nestjs/core": "^11.1.0", @@ -59,9 +64,12 @@ "change-case-commonjs": "^5.4.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "mikro-orm-pglite": "^0.5.1", + "nestjs-cls": "^6.0.1", "nestjs-pino": "4.3.0", "pg": "8.11.3", "pino-http": "10.4.0", + "pino-pretty": "^13.1.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", "socket.io-client": "^4.7.5", @@ -83,8 +91,8 @@ "@angular/cli": "~20.3.0", "@angular/compiler-cli": "20.3.4", "@angular/language-service": "20.3.4", - "@electric-sql/pglite": "^0.2.16", - "@faker-js/faker": "^8.4.1", + "@electric-sql/pglite": "^0.3.0", + "@faker-js/faker": "^10.0.0", "@jorgebodega/typeorm-factory": "^1.4.0", "@jorgebodega/typeorm-seeding": "^6.0.1", "@nestjs/schematics": "11.0.9", @@ -101,6 +109,9 @@ "@nx/webpack": "21.6.2", "@nx/workspace": "21.6.2", "@schematics/angular": "20.3.5", + "@suites/di.nestjs": "^3.0.1", + "@suites/doubles.vitest": "^3.0.1", + "@suites/unit": "^3.0.1", "@swc-node/register": "1.10.9", "@swc/cli": "~0.6.0", "@swc/core": "1.11.7", @@ -119,7 +130,6 @@ "jiti": "2.4.2", "jsdom": "~22.1.0", "jsonc-eslint-parser": "^2.1.0", - "knex-pglite": "^0.11.0", "ng-packagr": "20.3.0", "nx": "21.6.2", "pg-mem": "^3.0.2", @@ -149,5 +159,10 @@ }, "publishConfig": { "access": "public" + }, + "overrides": { + "mikro-orm-pglite": { + "@mikro-orm/postgresql": "^6.4.0" + } } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 1247f331..ba8c00e3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,13 +10,16 @@ "importHelpers": true, "target": "es2015", "module": "esnext", - "lib": ["es2020", "dom"], + "lib": ["es2022", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, "strict": true, "strictNullChecks": true, "baseUrl": ".", "paths": { + "@klerick/acl-json-api-nestjs": [ + "libs/acl-permissions/nestjs-acl-permissions/src/index.ts" + ], "@klerick/json-api-nestjs": [ "libs/json-api/json-api-nestjs/src/index.ts" ], @@ -41,6 +44,9 @@ "@nestjs-json-api/microorm-database": [ "libs/microorm-database/src/index.ts" ], + "@nestjs-json-api/microorm-database/entity": [ + "libs/microorm-database/src/lib/entities/index.ts" + ], "@nestjs-json-api/type-for-rpc": ["libs/type-for-rpc/src/index.ts"], "@nestjs-json-api/typeorm-database": [ "libs/typeorm-database/src/index.ts"