From c4c85eb1807af75c12604e1264284fd4c0f4765d Mon Sep 17 00:00:00 2001 From: mihir Date: Tue, 8 Sep 2026 21:24:48 +0530 Subject: [PATCH] feat(identity): add account schema, security primitives and migrations --- .env.example | 10 ++ .gitattributes | 2 + jest.config.cjs | 1 + package.json | 14 +- pnpm-lock.yaml | 123 +++++++++++++++ .../migration.sql | 142 ++++++++++++++++++ .../migration.sql | 14 ++ prisma/schema.prisma | 96 +++++++++++- scripts/test-migrations.mjs | 24 +++ src/common/validation.pipe.ts | 18 +++ src/config/environment.ts | 56 +++---- src/config/identity-environment.ts | 53 +++++++ src/identity/audit.ts | 14 ++ src/identity/identity.schemas.ts | 55 +++++++ src/identity/identity.types.ts | 10 ++ src/identity/password.service.ts | 41 +++++ src/identity/permissions.ts | 10 ++ src/identity/tokens.ts | 8 + test/identity-environment.spec.ts | 46 ++++++ test/password.spec.ts | 50 ++++++ tsconfig.json | 4 +- 21 files changed, 755 insertions(+), 36 deletions(-) create mode 100644 .gitattributes create mode 100644 prisma/migrations/202609080002_identity_access/migration.sql create mode 100644 prisma/migrations/202609080003_identity_integrity/migration.sql create mode 100644 scripts/test-migrations.mjs create mode 100644 src/common/validation.pipe.ts create mode 100644 src/config/identity-environment.ts create mode 100644 src/identity/audit.ts create mode 100644 src/identity/identity.schemas.ts create mode 100644 src/identity/identity.types.ts create mode 100644 src/identity/password.service.ts create mode 100644 src/identity/permissions.ts create mode 100644 src/identity/tokens.ts create mode 100644 test/identity-environment.spec.ts create mode 100644 test/password.spec.ts diff --git a/.env.example b/.env.example index 894381b..f0e306f 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,13 @@ NODE_ENV=development PORT=3000 DATABASE_URL=postgresql://mani:mani_local@localhost:5432/mani_candles CORS_ORIGINS=http://localhost:3001 + +SESSION_TTL_MINUTES=480 +RECOVERY_TTL_MINUTES=15 +# Optional recovery email: configure all five values together. +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=replace_me +# SMTP_PASSWORD=replace_me +# SMTP_FROM=support@example.com +# RECOVERY_URL=https://shop.example.com/reset diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/jest.config.cjs b/jest.config.cjs index aa32dfb..03f7394 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -7,6 +7,7 @@ module.exports = { 'src/**/*.ts', '!src/generated/**', '!src/main.ts', + '!src/cli/**', '!src/**/*.module.ts', ], coverageThreshold: { diff --git a/package.json b/package.json index 7486894..ff86e3b 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "build": "tsc -p tsconfig.build.json", "start": "node dist/main.js", "dev": "tsc -p tsconfig.build.json --watch", - "test": "jest --runInBand", - "test:coverage": "jest --runInBand --coverage", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --coverage", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit", @@ -21,7 +21,9 @@ "db:deploy": "prisma migrate deploy", "check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build", "start:watch": "node --watch dist/main.js", - "db:status": "prisma migrate status" + "db:status": "prisma migrate status", + "bootstrap:owner": "node dist/cli/bootstrap-owner.js", + "db:test": "node scripts/test-migrations.mjs" }, "dependencies": { "@nestjs/common": "^11.1.0", @@ -31,17 +33,23 @@ "@prisma/client": "^7.0.0", "dotenv": "^17.0.0", "helmet": "^8.0.0", + "nodemailer": "^10.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "zod": "^4.0.0" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.8", + "@electric-sql/pglite-socket": "^0.2.11", "@nestjs/testing": "^11.1.0", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", + "@types/nodemailer": "^8.0.1", + "@types/pg": "^8.23.1", "@types/supertest": "^6.0.0", "jest": "^30.0.0", + "pg": "^8.23.0", "prettier": "^3.0.0", "prisma": "^7.0.0", "supertest": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 631827c..effb8b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: helmet: specifier: ^8.0.0 version: 8.3.0 + nodemailer: + specifier: ^10.0.1 + version: 10.0.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -39,6 +42,12 @@ importers: specifier: ^4.0.0 version: 4.5.4 devDependencies: + '@electric-sql/pglite': + specifier: ^0.5.8 + version: 0.5.8 + '@electric-sql/pglite-socket': + specifier: ^0.2.11 + version: 0.2.11(@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite@0.5.8) '@nestjs/testing': specifier: ^11.1.0 version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3) @@ -51,12 +60,21 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.13.3 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 '@types/supertest': specifier: ^6.0.0 version: 6.0.3 jest: specifier: ^30.0.0 version: 30.5.1(@types/node@24.13.3)(supports-color@8.1.1) + pg: + specifier: ^8.23.0 + version: 8.23.0 prettier: specifier: ^3.0.0 version: 3.9.6 @@ -243,12 +261,60 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@electric-sql/pglite-age@0.0.9': + resolution: {integrity: sha512-IdNy5P5nxwKVeSPLjDLXkp/eYffH5roympM7fTG+g9ehzBrDnQ9TT2tfCJ2vRVcOSPQBp4oURzfRE19cs/x8ZA==} + peerDependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pg_hashids@0.0.9': + resolution: {integrity: sha512-NffsZH+FaRLp0WzsYG35BW/wOHLsTs325zjgp+PnKlWeGyW523Mbw4EZ1Awm02C3QWBU9p8Y2Fk02Al5NpRGUg==} + peerDependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pg_ivm@0.0.9': + resolution: {integrity: sha512-mbTcDLQyzeOG4rX6LLHhWDPJeQUv2xHvayKtf4oE7xujWEGWEDUEJP1+U0pXXELtIoWiUzzPBWjzKY1mRisqkQ==} + peerDependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pg_textsearch@0.0.10': + resolution: {integrity: sha512-tsL1rTYU4tzIC44htL7/0nS6C2XC5Srz0tfxU1LJelCqNIBkpFI9Esw1JbuVCaQKUHGDbPgAz8JYavNxwbuRVw==} + peerDependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pg_uuidv7@0.0.9': + resolution: {integrity: sha512-n/JbzoQMF9jFKFxGLRIzUfC1O4V2vTE0Pt3uoAO6/EP7dX5m1h4zBdezQAcT2JFostD0YWUdzHj9iYvcbUGM1A==} + peerDependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pgtap@0.0.9': + resolution: {integrity: sha512-VIyclfOawUN0IJROKiCnrVxsZQEG7tixgiC8c8nxp9TlBhI79MZqotoqlqvdPzGHmjWZlcgJ7tC8InOcy+2A/A==} + peerDependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pgvector@0.0.9': + resolution: {integrity: sha512-ue4iBW651gDQwBwn97Ekv1lYGPvXa1ymHbRbTCSL0Ib286PRDD1VDOwzwEoekZuO/wctMbTzKzlwdgDwYrqZ8A==} + peerDependencies: + '@electric-sql/pglite': 0.5.8 + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true peerDependencies: '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket@0.2.11': + resolution: {integrity: sha512-DKnJBf7+5zFCXXGZAGUvGoWKetPPvaQ7fLSGVqP5onzgBhV9vvTcHigireMROMglgovvhoXHaMOKqt69/vts4A==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.5.8 + '@electric-sql/pglite-age': 0.0.9 + '@electric-sql/pglite-pg_hashids': 0.0.9 + '@electric-sql/pglite-pg_ivm': 0.0.9 + '@electric-sql/pglite-pg_textsearch': 0.0.10 + '@electric-sql/pglite-pg_uuidv7': 0.0.9 + '@electric-sql/pglite-pgtap': 0.0.9 + '@electric-sql/pglite-pgvector': 0.0.9 + '@electric-sql/pglite-tools@0.3.3': resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} peerDependencies: @@ -257,6 +323,9 @@ packages: '@electric-sql/pglite@0.4.3': resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} + '@electric-sql/pglite@0.5.8': + resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -936,6 +1005,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} @@ -2138,6 +2210,10 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} + nodemailer@10.0.1: + resolution: {integrity: sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==} + engines: {node: '>=20.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2936,16 +3012,57 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-socket@0.2.11(@electric-sql/pglite-age@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_hashids@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_ivm@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_textsearch@0.0.10(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pg_uuidv7@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgtap@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8))(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + '@electric-sql/pglite-age': 0.0.9(@electric-sql/pglite@0.5.8) + '@electric-sql/pglite-pg_hashids': 0.0.9(@electric-sql/pglite@0.5.8) + '@electric-sql/pglite-pg_ivm': 0.0.9(@electric-sql/pglite@0.5.8) + '@electric-sql/pglite-pg_textsearch': 0.0.10(@electric-sql/pglite@0.5.8) + '@electric-sql/pglite-pg_uuidv7': 0.0.9(@electric-sql/pglite@0.5.8) + '@electric-sql/pglite-pgtap': 0.0.9(@electric-sql/pglite@0.5.8) + '@electric-sql/pglite-pgvector': 0.0.9(@electric-sql/pglite@0.5.8) + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 '@electric-sql/pglite@0.4.3': {} + '@electric-sql/pglite@0.5.8': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -3666,6 +3783,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 24.13.3 + '@types/pg@8.23.1': dependencies: '@types/node': 24.13.3 @@ -5041,6 +5162,8 @@ snapshots: node-releases@2.0.54: {} + nodemailer@10.0.1: {} + normalize-path@3.0.0: {} npm-run-path@4.0.1: diff --git a/prisma/migrations/202609080002_identity_access/migration.sql b/prisma/migrations/202609080002_identity_access/migration.sql new file mode 100644 index 0000000..b8d16a2 --- /dev/null +++ b/prisma/migrations/202609080002_identity_access/migration.sql @@ -0,0 +1,142 @@ +-- CreateEnum +CREATE TYPE "UserStatus" AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED'); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "email" VARCHAR(254) NOT NULL, + "name" VARCHAR(160) NOT NULL, + "password_hash" VARCHAR(256) NOT NULL, + "status" "UserStatus" NOT NULL DEFAULT 'PENDING', + "is_owner" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "roles" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "name" VARCHAR(80) NOT NULL, + "is_system" BOOLEAN NOT NULL DEFAULT false, + "permissions" TEXT[], + + CONSTRAINT "roles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_roles" ( + "user_id" UUID NOT NULL, + "role_id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + + CONSTRAINT "user_roles_pkey" PRIMARY KEY ("user_id","role_id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "token_hash" CHAR(64) NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "recovery_tokens" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "token_hash" CHAR(64) NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "recovery_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_events" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "actor_id" UUID, + "action" VARCHAR(80) NOT NULL, + "target_id" UUID, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rate_limits" ( + "key" CHAR(64) NOT NULL, + "hits" INTEGER NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE INDEX "users_organization_id_created_at_id_idx" ON "users"("organization_id", "created_at", "id"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_organization_id_email_key" ON "users"("organization_id", "email"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_id_organization_id_key" ON "users"("id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_organization_id_name_key" ON "roles"("organization_id", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_id_organization_id_key" ON "roles"("id", "organization_id"); + +-- CreateIndex +CREATE INDEX "user_roles_role_id_organization_id_idx" ON "user_roles"("role_id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "recovery_tokens_token_hash_key" ON "recovery_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "recovery_tokens_user_id_idx" ON "recovery_tokens"("user_id"); + +-- CreateIndex +CREATE INDEX "recovery_tokens_expires_at_idx" ON "recovery_tokens"("expires_at"); + +-- CreateIndex +CREATE INDEX "audit_events_organization_id_created_at_id_idx" ON "audit_events"("organization_id", "created_at", "id"); + +-- CreateIndex +CREATE INDEX "rate_limits_expires_at_idx" ON "rate_limits"("expires_at"); + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "roles" ADD CONSTRAINT "roles_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_organization_id_fkey" FOREIGN KEY ("role_id", "organization_id") REFERENCES "roles"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recovery_tokens" ADD CONSTRAINT "recovery_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/202609080003_identity_integrity/migration.sql b/prisma/migrations/202609080003_identity_integrity/migration.sql new file mode 100644 index 0000000..a92a594 --- /dev/null +++ b/prisma/migrations/202609080003_identity_integrity/migration.sql @@ -0,0 +1,14 @@ +-- Database-level invariants complement HTTP validation. +ALTER TABLE users ADD CONSTRAINT users_email_normalized CHECK (email = lower(email)); +CREATE UNIQUE INDEX users_single_owner ON users (is_owner) WHERE is_owner = true; + +-- Application-facing audit history is append-only. A dedicated DBA retention +-- procedure is required if records ever need to be archived or removed. +CREATE FUNCTION reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'Audit events are append-only'; +END; +$$; +CREATE TRIGGER audit_events_append_only +BEFORE UPDATE OR DELETE ON audit_events +FOR EACH ROW EXECUTE FUNCTION reject_audit_mutation(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dfa368b..3bd0f72 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,17 +3,101 @@ generator client { output = "../src/generated/prisma" moduleFormat = "cjs" } - datasource db { provider = "postgresql" } - -// The owning business. Supplier/customer organizations belong to later modules. +enum UserStatus { + PENDING + ACTIVE + SUSPENDED +} model Organization { - id String @id @default(uuid()) @db.Uuid - name String @db.VarChar(160) + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(160) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) - + users User[] + roles Role[] + auditEvents AuditEvent[] @@map("organizations") } +model User { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + email String @db.VarChar(254) + name String @db.VarChar(160) + passwordHash String @map("password_hash") @db.VarChar(256) + status UserStatus @default(PENDING) + isOwner Boolean @default(false) @map("is_owner") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + roles UserRole[] + sessions Session[] + recoveryTokens RecoveryToken[] + @@unique([organizationId, email]) + @@unique([id, organizationId]) + @@index([organizationId, createdAt, id]) + @@map("users") +} +model Role { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + name String @db.VarChar(80) + isSystem Boolean @default(false) @map("is_system") + permissions String[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + users UserRole[] + @@unique([organizationId, name]) + @@unique([id, organizationId]) + @@map("roles") +} +model UserRole { + userId String @map("user_id") @db.Uuid + roleId String @map("role_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Cascade) + role Role @relation(fields: [roleId, organizationId], references: [id, organizationId], onDelete: Restrict) + @@id([userId, roleId]) + @@index([roleId, organizationId]) + @@map("user_roles") +} +model Session { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + tokenHash String @unique @map("token_hash") @db.Char(64) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId]) + @@index([expiresAt]) + @@map("sessions") +} +model RecoveryToken { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + tokenHash String @unique @map("token_hash") @db.Char(64) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId]) + @@index([expiresAt]) + @@map("recovery_tokens") +} +model AuditEvent { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + actorId String? @map("actor_id") @db.Uuid + action String @db.VarChar(80) + targetId String? @map("target_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + @@index([organizationId, createdAt, id]) + @@map("audit_events") +} +model RateLimit { + key String @id @db.Char(64) + hits Int + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + @@index([expiresAt]) + @@map("rate_limits") +} diff --git a/scripts/test-migrations.mjs b/scripts/test-migrations.mjs new file mode 100644 index 0000000..1d928fb --- /dev/null +++ b/scripts/test-migrations.mjs @@ -0,0 +1,24 @@ +import { PGlite } from '@electric-sql/pglite'; +import { PGLiteSocketServer } from '@electric-sql/pglite-socket'; +import { spawn } from 'node:child_process'; + +const db = await PGlite.create(); +const server = new PGLiteSocketServer({ db, port: 0, host: '127.0.0.1' }); +await server.start(); +try { + const status = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['scripts/verify-migrations.mjs'], { + stdio: 'inherit', + env: { + ...process.env, + DATABASE_URL: `postgresql://postgres:postgres@${server.getServerConn()}/postgres`, + }, + }); + child.once('error', reject); + child.once('exit', (code) => resolve(code ?? 1)); + }); + process.exitCode = status; +} finally { + await server.stop(); + await db.close(); +} diff --git a/src/common/validation.pipe.ts b/src/common/validation.pipe.ts new file mode 100644 index 0000000..17f19fa --- /dev/null +++ b/src/common/validation.pipe.ts @@ -0,0 +1,18 @@ +import { BadRequestException, PipeTransform } from '@nestjs/common'; +import { z } from 'zod'; + +export class SchemaPipe implements PipeTransform { + constructor(private readonly schema: z.ZodType) {} + transform(value: unknown): T { + const result = this.schema.safeParse(value); + if (!result.success) { + throw new BadRequestException({ + message: 'Invalid request', + fields: [ + ...new Set(result.error.issues.map((issue) => issue.path.join('.'))), + ], + }); + } + return result.data; + } +} diff --git a/src/config/environment.ts b/src/config/environment.ts index 221d126..ac46ee8 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -1,32 +1,36 @@ +import { identityEnvironmentShape, validateSmtp } from './identity-environment'; import { z } from 'zod'; -const schema = z.object({ - NODE_ENV: z - .enum(['development', 'test', 'production']) - .default('development'), - PORT: z.coerce.number().int().min(1).max(65535).default(3000), - DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)), - CORS_ORIGINS: z - .string() - .default('') - .transform((value) => - value - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean), - ) - .pipe( - z.array( - z.url().refine((value) => { - if (!URL.canParse(value)) return false; - const url = new URL(value); - return ( - ['http:', 'https:'].includes(url.protocol) && url.origin === value - ); - }), +const schema = z + .object({ + ...identityEnvironmentShape, + NODE_ENV: z + .enum(['development', 'test', 'production']) + .default('development'), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)), + CORS_ORIGINS: z + .string() + .default('') + .transform((value) => + value + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean), + ) + .pipe( + z.array( + z.url().refine((value) => { + if (!URL.canParse(value)) return false; + const url = new URL(value); + return ( + ['http:', 'https:'].includes(url.protocol) && url.origin === value + ); + }), + ), ), - ), -}); + }) + .superRefine(validateSmtp); export type Environment = z.infer; diff --git a/src/config/identity-environment.ts b/src/config/identity-environment.ts new file mode 100644 index 0000000..fd36ccc --- /dev/null +++ b/src/config/identity-environment.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +export const identityEnvironmentShape = { + SESSION_TTL_MINUTES: z.coerce.number().int().min(5).max(10080).default(480), + RECOVERY_TTL_MINUTES: z.coerce.number().int().min(5).max(60).default(15), + SMTP_HOST: z.string().min(1).optional(), + SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587), + SMTP_USER: z.string().min(1).optional(), + SMTP_PASSWORD: z.string().min(1).optional(), + SMTP_FROM: z.email().optional(), + RECOVERY_URL: z + .url() + .refine((value) => { + if (!URL.canParse(value)) return false; + const url = new URL(value); + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + !url.hash && + !url.search + ); + }) + .optional(), +}; +export function validateSmtp( + input: { + SMTP_HOST?: string; + SMTP_USER?: string; + SMTP_PASSWORD?: string; + SMTP_FROM?: string; + RECOVERY_URL?: string; + }, + context: z.RefinementCtx, +) { + const fields = [ + 'SMTP_HOST', + 'SMTP_USER', + 'SMTP_PASSWORD', + 'SMTP_FROM', + 'RECOVERY_URL', + ] as const; + if (fields.some((field) => input[field] !== undefined)) { + for (const field of fields) { + if (!input[field]) + context.addIssue({ + code: 'custom', + path: [field], + message: 'Required for recovery email', + }); + } + } +} diff --git a/src/identity/audit.ts b/src/identity/audit.ts new file mode 100644 index 0000000..81d89c5 --- /dev/null +++ b/src/identity/audit.ts @@ -0,0 +1,14 @@ +import type { Prisma } from '../generated/prisma/client'; + +export function recordAudit( + tx: Prisma.TransactionClient, + organizationId: string, + actorId: string | null, + action: string, + targetId: string | null = null, +) { + // Deliberately accept no arbitrary payload: passwords and tokens cannot enter audit data. + return tx.auditEvent.create({ + data: { organizationId, actorId, action, targetId }, + }); +} diff --git a/src/identity/identity.schemas.ts b/src/identity/identity.schemas.ts new file mode 100644 index 0000000..8365ef7 --- /dev/null +++ b/src/identity/identity.schemas.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { PERMISSIONS } from './permissions'; + +export const email = z + .email() + .max(254) + .transform((value) => value.toLowerCase()); +export const password = z.string().min(15).max(128); +const name = z.string().trim().min(1).max(160); +export const loginSchema = z + .object({ + organizationId: z.uuid(), + email, + password: z.string().min(1).max(128), + }) + .strict(); +export const recoveryRequestSchema = loginSchema.omit({ password: true }); +export const recoveryResetSchema = z + .object({ + token: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + password, + }) + .strict(); +export const createUserSchema = z.object({ email, name, password }).strict(); +export const statusSchema = z + .object({ status: z.enum(['ACTIVE', 'SUSPENDED']) }) + .strict(); +export const roleSchema = z + .object({ + name: z.string().trim().min(1).max(80), + permissions: z + .array(z.enum(PERMISSIONS)) + .max(PERMISSIONS.length) + .refine((values) => new Set(values).size === values.length), + }) + .strict(); +export const assignmentsSchema = z + .object({ + roleIds: z + .array(z.uuid()) + .max(20) + .refine((ids) => new Set(ids).size === ids.length), + }) + .strict(); +export const pageSchema = z + .object({ + limit: z.coerce.number().int().min(1).max(100).default(25), + offset: z.coerce.number().int().min(0).max(10000).default(0), + }) + .strict(); +export type LoginInput = z.infer; +export type RecoveryInput = z.infer; +export type CreateUserInput = z.infer; +export type RoleInput = z.infer; +export type PageInput = z.infer; diff --git a/src/identity/identity.types.ts b/src/identity/identity.types.ts new file mode 100644 index 0000000..cde2108 --- /dev/null +++ b/src/identity/identity.types.ts @@ -0,0 +1,10 @@ +import type { Request } from 'express'; +export interface Principal { + userId: string; + organizationId: string; + sessionId: string; + permissions: string[]; +} +export interface AuthenticatedRequest extends Request { + principal: Principal; +} diff --git a/src/identity/password.service.ts b/src/identity/password.service.ts new file mode 100644 index 0000000..a7d9403 --- /dev/null +++ b/src/identity/password.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; + +const COST = 32768; +function derive(password: string, salt: string): Promise { + return new Promise((resolve, reject) => { + scrypt( + password, + salt, + 64, + { N: COST, r: 8, p: 3, maxmem: 64 * 1024 * 1024 }, + (error, key) => (error ? reject(error) : resolve(key)), + ); + }); +} +@Injectable() +export class PasswordService { + async hash(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + const key = await derive(password, salt); + return `scrypt-v1$${salt}$${key.toString('hex')}`; + } + async verify(password: string, encoded: string): Promise { + const [version, salt, hash, extra] = encoded.split('$'); + if ( + version !== 'scrypt-v1' || + !/^[a-f0-9]{32}$/.test(salt ?? '') || + !/^[a-f0-9]{128}$/.test(hash ?? '') || + extra !== undefined + ) + return false; + return timingSafeEqual( + await derive(password, salt), + Buffer.from(hash, 'hex'), + ); + } + // Equal-cost work for an unknown account; no dummy credentials are usable. + async dummyVerify(password: string): Promise { + await derive(password, '00000000000000000000000000000000'); + } +} diff --git a/src/identity/permissions.ts b/src/identity/permissions.ts new file mode 100644 index 0000000..b6392ae --- /dev/null +++ b/src/identity/permissions.ts @@ -0,0 +1,10 @@ +export const PERMISSIONS = [ + 'users.read', + 'users.create', + 'users.approve', + 'users.roles.assign', + 'roles.read', + 'roles.manage', + 'audit.read', +] as const; +export type Permission = (typeof PERMISSIONS)[number]; diff --git a/src/identity/tokens.ts b/src/identity/tokens.ts new file mode 100644 index 0000000..372bcef --- /dev/null +++ b/src/identity/tokens.ts @@ -0,0 +1,8 @@ +import { createHash, randomBytes } from 'node:crypto'; +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} +export function issueToken(): { token: string; tokenHash: string } { + const token = randomBytes(32).toString('base64url'); + return { token, tokenHash: hashToken(token) }; +} diff --git a/test/identity-environment.spec.ts b/test/identity-environment.spec.ts new file mode 100644 index 0000000..de39eac --- /dev/null +++ b/test/identity-environment.spec.ts @@ -0,0 +1,46 @@ +import { parseEnvironment } from '../src/config/environment'; + +describe('identity environment', () => { + const base = { DATABASE_URL: 'postgresql://localhost/mani' }; + const smtp = { + SMTP_HOST: 'smtp.example.com', + SMTP_USER: 'user', + SMTP_PASSWORD: 'secret', + SMTP_FROM: 'support@example.com', + RECOVERY_URL: 'https://shop.example.com/reset', + }; + it('validates complete SMTP and lifetime configuration', () => { + expect( + parseEnvironment({ ...base, ...smtp, SESSION_TTL_MINUTES: '60' }), + ).toMatchObject({ SESSION_TTL_MINUTES: 60, SMTP_PORT: 587 }); + }); + it('rejects partially configured SMTP without exposing secrets', () => { + expect(() => + parseEnvironment({ ...base, SMTP_PASSWORD: 'sensitive' }), + ).toThrow('SMTP_HOST'); + try { + parseEnvironment({ ...base, SMTP_PASSWORD: 'sensitive' }); + } catch (error) { + expect(String(error)).not.toContain('sensitive'); + } + }); + it.each([ + 'http://shop.example.com/reset', + 'https://shop.example.com/reset?token=x', + 'https://user:pass@shop.example.com/reset', + 'https://shop.example.com/reset#token', + 'not-url', + ])('rejects unsafe recovery URLs %s', (url) => { + expect(() => + parseEnvironment({ ...base, ...smtp, RECOVERY_URL: url }), + ).toThrow('RECOVERY_URL'); + }); + it.each([{ SESSION_TTL_MINUTES: '0' }, { RECOVERY_TTL_MINUTES: '61' }])( + 'bounds expiry settings %j', + (values) => { + expect(() => parseEnvironment({ ...base, ...values })).toThrow( + 'Invalid environment', + ); + }, + ); +}); diff --git a/test/password.spec.ts b/test/password.spec.ts new file mode 100644 index 0000000..e73d57e --- /dev/null +++ b/test/password.spec.ts @@ -0,0 +1,50 @@ +import { PasswordService } from '../src/identity/password.service'; +import { issueToken, hashToken } from '../src/identity/tokens'; +import { createUserSchema, roleSchema } from '../src/identity/identity.schemas'; + +describe('passwords and token primitives', () => { + const passwords = new PasswordService(); + it('salts equal passwords independently and verifies them', async () => { + const first = await passwords.hash('a secure long passphrase'); + const second = await passwords.hash('a secure long passphrase'); + expect(first).not.toBe(second); + expect(await passwords.verify('a secure long passphrase', first)).toBe( + true, + ); + expect(await passwords.verify('wrong', first)).toBe(false); + await passwords.dummyVerify('anything'); + }); + it.each([ + '', + 'bcrypt$bad', + 'scrypt-v1$x$x', + 'scrypt-v1$' + 'a'.repeat(32) + '$' + 'a'.repeat(128) + '$extra', + ])('fails closed for malformed stored hash %s', async (value) => { + expect(await passwords.verify('password', value)).toBe(false); + }); + it('issues random URL-safe tokens and one-way digests', () => { + const first = issueToken(); + expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(first.tokenHash).toMatch(/^[a-f0-9]{64}$/); + expect(hashToken(first.token)).toBe(first.tokenHash); + expect(issueToken().token).not.toBe(first.token); + }); + it('validates long passphrases, known permissions and duplicate permissions', () => { + expect( + createUserSchema.safeParse({ + email: 'a@example.com', + name: 'A', + password: 'short', + }).success, + ).toBe(false); + expect( + roleSchema.safeParse({ name: 'Role', permissions: ['anything'] }).success, + ).toBe(false); + expect( + roleSchema.safeParse({ + name: 'Role', + permissions: ['users.read', 'users.read'], + }).success, + ).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 7350fb1..b1866e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,9 @@ "skipLibCheck": true, "outDir": "dist", "sourceMap": true, - "isolatedModules": true + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true }, "include": ["src/**/*.ts", "test/**/*.ts", "prisma.config.ts"] }